/*
This file belongs to the Servoy development and deployment environment, Copyright (C) 1997-2012 Servoy BV
This program is free software; you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation; either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along
with this program; if not, see http://www.gnu.org/licenses or write to the Free
Software Foundation,Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
*/
package com.servoy.j2db.smart.dataui;
import java.awt.event.AdjustmentEvent;
import java.awt.event.AdjustmentListener;
import java.util.HashMap;
import javax.swing.ListModel;
import javax.swing.ScrollPaneConstants;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import com.servoy.j2db.IApplication;
import com.servoy.j2db.dataprocessing.IValueList;
import com.servoy.j2db.persistence.Field;
import com.servoy.j2db.ui.IFieldComponent;
import com.servoy.j2db.ui.scripting.AbstractRuntimeValuelistComponent;
import com.servoy.j2db.util.Utils;
import com.servoy.j2db.util.model.ComboModelListModelWrapper;
import com.servoy.j2db.util.model.IEditListModel;
/**
* Spinner-like smart client component. For now it is based on DataChoice implementation but if we will want it to be
* editable we will have to make it similar to editable combobox but based on JSpinner.
* @author acostescu
*/
public class DataSpinner extends DataChoice
{
// while we internally modify list input element or scroll to an index, ignore adjustment listeners that are only interested in direct user actions;
// while list cell height changes, we must ignore generated (scroll) events - as those will probably lead to incorrect indexes as they are not a user wish to change the value
private boolean disableUserAdjustmentListeners = false;
public DataSpinner(IApplication app, AbstractRuntimeValuelistComponent<IFieldComponent> scriptable, IValueList vl)
{
super(app, scriptable, vl, Field.SPINNER, false);
super.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
getVerticalScrollBar().addAdjustmentListener(new AdjustmentListener()
{
public void adjustmentValueChanged(AdjustmentEvent e)
{
if (disableUserAdjustmentListeners || e.getValueIsAdjusting()) return;
// int y = (int)(enclosedComponent.getHeight() * (((double)e.getValue()) / e.getAdjustable().getMaximum()));
// final int idx = enclosedComponent.locationToIndex(new Point(getViewport().getViewPosition().x, y));
final int idx = getVisibleIndex();
if (idx > 0 && !isRowSelected(idx) && idx < list.getSize() + 1) //list.getSize() returns SpinnerModel -2 size. We have +2 size on the SpinnerModel for First and last blank model elements.
{
setSelectedIndex(idx);
}
else if (list.getSize() > 0 && idx == 0 && list.getSelectedRow() != -1) //user reached the first element and pressed again 'up' . Circular behavior should go to last
{
setSelectedIndex(list.getSize());
ensureSelectedIsVisible(false); // do not allow blank value as a user choice
}
else if (idx > list.getSize())
// user has reached the last element .Circular behavior should go to first element
{
setSelectedIndex(1);
ensureSelectedIsVisible(false); // do not allow blank value as a user choice
}
}
/**
* This method belongs to the anonymous inner class AdjustmentListener(){...} .
* Currently only it uses this method when needing to change the selected index.
* @param idx
*/
private void setSelectedIndex(final int idx)
{
boolean focused = enclosedComponent.hasFocus();
if (!focused) enclosedComponent.requestFocus();
// if you would create a new record with non-nullable dataProvider for this spinner, type something in another text field then click
// on an arrow of the spinner, this would determine something like spinner:commit(someValue)->currentComponentNotCommitted->currentComponentCommit->TextFieldCommit->notifyDisplayAdaptersRecordChanged->spinner:changeValueTo(null)
// after that the spinner:commit continued with wrong (null) value... so we will do this later to allow the currentComponent to commit it's value
Runnable r = new Runnable()
{
public void run()
{
enclosedComponent.editCellAt(idx);
setElementAt(Boolean.TRUE, idx);
boolean old = disableUserAdjustmentListeners;
try
{
enclosedComponent.setSelectedIndex(idx);
}
finally
{
disableUserAdjustmentListeners = old;
}
}
};
if (!focused)
{
application.invokeLater(r);
}
else
{
r.run();
}
}
});
enclosedComponent.getSelectionModel().addListSelectionListener(new ListSelectionListener()
{
public void valueChanged(ListSelectionEvent e)
{
if (disableUserAdjustmentListeners || e.getValueIsAdjusting()) return;
int idx = enclosedComponent.getSelectedIndex();
if (idx > 0)
{
if (!isRowSelected(idx)) setElementAt(Boolean.TRUE, idx);
}
else if (list.getSize() > 0)
{
int previousIdx = (e.getFirstIndex() == idx) ? e.getLastIndex() : e.getFirstIndex();
boolean old = disableUserAdjustmentListeners;
try
{
enclosedComponent.setSelectedIndex(previousIdx);
}
finally
{
disableUserAdjustmentListeners = old;
}
}
}
});
enclosedComponent.getModel().addListDataListener(new ListDataListener()
{
public void intervalRemoved(ListDataEvent e)
{
ensureSelectedIsVisible(false);
}
public void intervalAdded(ListDataEvent e)
{
ensureSelectedIsVisible(false);
}
public void contentsChanged(ListDataEvent e)
{
// wait for selection changed (-1) event
if (e.getIndex0() == e.getIndex1() && e.getIndex0() >= 0) return;
ensureSelectedIsVisible(false);
}
});
// listen for view port resize; adding a component listener for this does not help
// because that one decouples to invokeLater(...) but when in table-view the spinner renderer/editor needs to be paint ready right away
getViewport().addChangeListener(new ChangeListener()
{
public void stateChanged(ChangeEvent e)
{
pinCellHeight();
}
});
pinCellHeight();
}
private Object valueObject;
@Override
public void setValueObject(Object data)
{
if (Utils.equalObjects(getValueObject(), data) && list.getSelectedItem() != null)
{
// do nothing, data may be invalid value; same condition as for combo
return;
}
valueObject = data;
super.setValueObject(data);
}
protected int getVisibleIndex()
{
return enclosedComponent.locationToIndex(getViewport().getViewPosition());
}
@Override
protected ListModel createJListModel(ComboModelListModelWrapper comboModel)
{
return new SpinnerModel(comboModel);
}
@Override
protected boolean isRowSelected(int idx)
{
return idx > 0 ? list.isRowSelected(idx - 1) : false;
}
@Override
protected void setElementAt(Object b, int idx)
{
if (idx > 0) list.setElementAt(b, idx - 1);
}
protected void pinCellHeight()
{
int height = getViewport().getHeight() - getViewport().getInsets().top - getViewport().getInsets().bottom;
if (height != 0 && enclosedComponent.getFixedCellHeight() != height)
{
boolean old = disableUserAdjustmentListeners;
disableUserAdjustmentListeners = true;
try
{
enclosedComponent.setFixedCellHeight(height);
validate();
}
finally
{
disableUserAdjustmentListeners = old;
}
ensureSelectedIsVisible(true); // force because even if there is only a few pixels change (table view when you use keys to navigate to a cell) that might not change the visible index, we still have to reposition to where the selected index cell y begins
}
}
@Override
public void setVerticalScrollBarPolicy(int policy)
{
// spinner always shows vertical scroll bar
}
@Override
public void setReadOnly(boolean b)
{
super.setReadOnly(b);
applyScrollBarPolicy();
}
@Override
public void setEditable(boolean b)
{
super.setEditable(b);
applyScrollBarPolicy();
}
protected void ensureSelectedIsVisible(boolean force)
{
// avoid doing this needlessly, currently this method gets called at least once for each list item when selection changes from outside the component
int idx = list.getSelectedRow() + 1;
if (force || (getVisibleIndex() != idx))
{
boolean old = disableUserAdjustmentListeners;
disableUserAdjustmentListeners = true;
try
{
enclosedComponent.ensureIndexIsVisible(idx);
enclosedComponent.setSelectedIndex(idx);
}
finally
{
disableUserAdjustmentListeners = old;
}
}
}
@Override
public void setComponentEnabled(boolean b)
{
super.setComponentEnabled(b);
applyScrollBarPolicy();
}
private void applyScrollBarPolicy()
{
if (isEnabled() && !isReadOnly())
{
setVerticalScrollBarPolicySpecial(VERTICAL_SCROLLBAR_ALWAYS);
}
else
{
setVerticalScrollBarPolicySpecial(VERTICAL_SCROLLBAR_NEVER);
}
}
@Override
protected boolean shouldPaintSelection()
{
return false;
}
/**
* Model that allows a (first) null entry for situations where the dataProvider value is not part of the valuelist (so the component should show blank content).
*
* @author acostescu
*/
protected class SpinnerModel implements IEditListModel
{
private final ComboModelListModelWrapper<Object> comboModel;
private final HashMap<ListDataListener, ListDataListener> lTol = new HashMap<ListDataListener, ListDataListener>();
public SpinnerModel(ComboModelListModelWrapper<Object> comboModel)
{
this.comboModel = comboModel;
}
public int getSize()
{
/*
* we have 2 empty model elements one in the front of the list and one at the end of the list The first empty element is needed for the reason in
* the SpinnerModel Description, plus detect when the beginning of the list reached (for circular spinner model) The last empty element is needed
* only for detection of the end of the list (for circular cycling of the spinner).
*/
return comboModel.getSize() + 2;
}
public Object getElementAt(int index)
{
if (index < 1) return valueObject;
return comboModel.getElementAt(index - 1); //returns null for the last empty element from getSize()+2
}
public void addListDataListener(final ListDataListener l)
{
if (!lTol.containsKey(l))
{
ListDataListener toL = new ListDataListener()
{
public void intervalRemoved(ListDataEvent e)
{
ListDataEvent myE = new ListDataEvent(this, e.getType(), e.getIndex0() >= 0 ? e.getIndex0() + 1 : e.getIndex0(), e.getIndex1() >= 0
? e.getIndex1() + 1 : e.getIndex1());
l.intervalRemoved(myE);
}
public void intervalAdded(ListDataEvent e)
{
ListDataEvent myE = new ListDataEvent(this, e.getType(), e.getIndex0() >= 0 ? e.getIndex0() + 1 : e.getIndex0(), e.getIndex1() >= 0
? e.getIndex1() + 1 : e.getIndex1());
l.intervalAdded(myE);
}
public void contentsChanged(ListDataEvent e)
{
ListDataEvent myE = new ListDataEvent(this, e.getType(), e.getIndex0() >= 0 ? e.getIndex0() + 1 : e.getIndex0(), e.getIndex1() >= 0
? e.getIndex1() + 1 : e.getIndex1());
l.contentsChanged(myE);
}
};
lTol.put(l, toL);
comboModel.addListDataListener(toL);
}
}
public void removeListDataListener(ListDataListener l)
{
if (lTol.containsKey(l))
{
comboModel.removeListDataListener(lTol.remove(l));
}
}
public boolean isCellEditable(int rowIndex)
{
return comboModel.isCellEditable(rowIndex - 1);
}
public void setElementAt(Object aValue, int rowIndex)
{
DataSpinner.this.setElementAt(aValue, rowIndex);
}
}
}