qt: Use fixed-point arithmetic in amount spinbox

Fixes various issues and cleans up code

- Fixes issue #4500: Amount widget +/- has floating point rounding artifacts
- Amount box can now be emptied again, without clearing to 0

Also aligns the amount to the right, as in other places.
This commit is contained in:
Wladimir J. van der Laan 2014-07-18 16:31:13 +02:00
parent d5a3fd10e5
commit 91cce1732b
6 changed files with 187 additions and 113 deletions

View File

@ -145,6 +145,7 @@ BITCOIN_MM = \
QT_MOC = \ QT_MOC = \
qt/bitcoin.moc \ qt/bitcoin.moc \
qt/bitcoinamountfield.moc \
qt/intro.moc \ qt/intro.moc \
qt/overviewpage.moc \ qt/overviewpage.moc \
qt/rpcconsole.moc qt/rpcconsole.moc

View File

@ -9,63 +9,185 @@
#include "qvaluecombobox.h" #include "qvaluecombobox.h"
#include <QApplication> #include <QApplication>
#include <QDoubleSpinBox> #include <QAbstractSpinBox>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QKeyEvent> #include <QKeyEvent>
#include <qmath.h> // for qPow() #include <QLineEdit>
// QDoubleSpinBox that shows SI-style thin space thousands separators /** QSpinBox that uses fixed-point numbers internally and uses our own
class AmountSpinBox: public QDoubleSpinBox * formatting/parsing functions.
*/
class AmountSpinBox: public QAbstractSpinBox
{ {
Q_OBJECT
public: public:
explicit AmountSpinBox(QWidget *parent): explicit AmountSpinBox(QWidget *parent):
QDoubleSpinBox(parent) QAbstractSpinBox(parent),
currentUnit(BitcoinUnits::BTC),
singleStep(100000) // satoshis
{ {
setAlignment(Qt::AlignRight);
connect(lineEdit(), SIGNAL(textEdited(QString)), this, SIGNAL(valueChanged()));
} }
QString textFromValue(double value) const
QValidator::State validate(QString &text, int &pos) const
{ {
QStringList parts = QDoubleSpinBox::textFromValue(value).split("."); if(text.isEmpty())
QString quotient_str = parts[0]; return QValidator::Intermediate;
QString remainder_str; bool valid = false;
if(parts.size() > 1) parse(text, &valid);
remainder_str = parts[1]; /* Make sure we return Intermediate so that fixup() is called on defocus */
return valid ? QValidator::Intermediate : QValidator::Invalid;
}
// Code duplication between here and BitcoinUnits::format void fixup(QString &input) const
// TODO: Figure out how to share this code {
QChar thin_sp(THIN_SP_CP); bool valid = false;
int q_size = quotient_str.size(); qint64 val = parse(input, &valid);
if (q_size > 4) if(valid)
for (int i = 3; i < q_size; i += 3) {
quotient_str.insert(q_size - i, thin_sp); input = BitcoinUnits::format(currentUnit, val, false, BitcoinUnits::separatorAlways);
lineEdit()->setText(input);
}
}
int r_size = remainder_str.size(); qint64 value(bool *valid_out=0) const
if (r_size > 4) {
for (int i = 3, adj = 0; i < r_size; i += 3, adj++) return parse(text(), valid_out);
remainder_str.insert(i + adj, thin_sp); }
if(remainder_str.isEmpty()) void setValue(qint64 value)
return quotient_str; {
lineEdit()->setText(BitcoinUnits::format(currentUnit, value, false, BitcoinUnits::separatorAlways));
emit valueChanged();
}
void stepBy(int steps)
{
bool valid = false;
qint64 val = value(&valid);
val = val + steps * singleStep;
val = qMin(qMax(val, Q_INT64_C(0)), BitcoinUnits::maxMoney());
setValue(val);
}
StepEnabled stepEnabled() const
{
StepEnabled rv = 0;
if(text().isEmpty()) // Allow step-up with empty field
return StepUpEnabled;
bool valid = false;
qint64 val = value(&valid);
if(valid)
{
if(val > 0)
rv |= StepDownEnabled;
if(val < BitcoinUnits::maxMoney())
rv |= StepUpEnabled;
}
return rv;
}
void setDisplayUnit(int unit)
{
bool valid = false;
qint64 val = value(&valid);
currentUnit = unit;
if(valid)
setValue(val);
else else
return quotient_str + QString(".") + remainder_str; clear();
} }
QValidator::State validate (QString &text, int &pos) const
void setSingleStep(qint64 step)
{ {
QString s(BitcoinUnits::removeSpaces(text)); singleStep = step;
return QDoubleSpinBox::validate(s, pos);
} }
double valueFromText(const QString& text) const
QSize minimumSizeHint() const
{ {
return QDoubleSpinBox::valueFromText(BitcoinUnits::removeSpaces(text)); if(cachedMinimumSizeHint.isEmpty())
{
ensurePolished();
const QFontMetrics fm(fontMetrics());
int h = lineEdit()->minimumSizeHint().height();
int w = fm.width(BitcoinUnits::format(BitcoinUnits::BTC, BitcoinUnits::maxMoney(), false, BitcoinUnits::separatorAlways));
w += 2; // cursor blinking space
QStyleOptionSpinBox opt;
initStyleOption(&opt);
QSize hint(w, h);
QSize extra(35, 6);
opt.rect.setSize(hint + extra);
extra += hint - style()->subControlRect(QStyle::CC_SpinBox, &opt,
QStyle::SC_SpinBoxEditField, this).size();
// get closer to final result by repeating the calculation
opt.rect.setSize(hint + extra);
extra += hint - style()->subControlRect(QStyle::CC_SpinBox, &opt,
QStyle::SC_SpinBoxEditField, this).size();
hint += extra;
opt.rect = rect();
cachedMinimumSizeHint = style()->sizeFromContents(QStyle::CT_SpinBox, &opt, hint, this)
.expandedTo(QApplication::globalStrut());
}
return cachedMinimumSizeHint;
} }
private:
int currentUnit;
qint64 singleStep;
mutable QSize cachedMinimumSizeHint;
/**
* Parse a string into a number of base monetary units and
* return validity.
* @note Must return 0 if !valid.
*/
qint64 parse(const QString &text, bool *valid_out=0) const
{
qint64 val = 0;
bool valid = BitcoinUnits::parse(currentUnit, text, &val);
if(valid)
{
if(val < 0 || val > BitcoinUnits::maxMoney())
valid = false;
}
if(valid_out)
*valid_out = valid;
return valid ? val : 0;
}
protected:
bool event(QEvent *event)
{
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease)
{
QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Comma)
{
// Translate a comma into a period
QKeyEvent periodKeyEvent(event->type(), Qt::Key_Period, keyEvent->modifiers(), ".", keyEvent->isAutoRepeat(), keyEvent->count());
return QAbstractSpinBox::event(&periodKeyEvent);
}
}
return QAbstractSpinBox::event(event);
}
signals:
void valueChanged();
}; };
#include "bitcoinamountfield.moc"
BitcoinAmountField::BitcoinAmountField(QWidget *parent) : BitcoinAmountField::BitcoinAmountField(QWidget *parent) :
QWidget(parent), QWidget(parent),
amount(0), amount(0)
currentUnit(-1)
{ {
nSingleStep = 100000; // satoshis
amount = new AmountSpinBox(this); amount = new AmountSpinBox(this);
amount->setLocale(QLocale::c()); amount->setLocale(QLocale::c());
amount->installEventFilter(this); amount->installEventFilter(this);
@ -85,21 +207,13 @@ BitcoinAmountField::BitcoinAmountField(QWidget *parent) :
setFocusProxy(amount); setFocusProxy(amount);
// If one if the widgets changes, the combined content changes as well // If one if the widgets changes, the combined content changes as well
connect(amount, SIGNAL(valueChanged(QString)), this, SIGNAL(textChanged())); connect(amount, SIGNAL(valueChanged()), this, SIGNAL(valueChanged()));
connect(unit, SIGNAL(currentIndexChanged(int)), this, SLOT(unitChanged(int))); connect(unit, SIGNAL(currentIndexChanged(int)), this, SLOT(unitChanged(int)));
// Set default based on configuration // Set default based on configuration
unitChanged(unit->currentIndex()); unitChanged(unit->currentIndex());
} }
void BitcoinAmountField::setText(const QString &text)
{
if (text.isEmpty())
amount->clear();
else
amount->setValue(BitcoinUnits::removeSpaces(text).toDouble());
}
void BitcoinAmountField::clear() void BitcoinAmountField::clear()
{ {
amount->clear(); amount->clear();
@ -108,16 +222,9 @@ void BitcoinAmountField::clear()
bool BitcoinAmountField::validate() bool BitcoinAmountField::validate()
{ {
bool valid = true; bool valid = false;
if (amount->value() == 0.0) value(&valid);
valid = false;
else if (!BitcoinUnits::parse(currentUnit, text(), 0))
valid = false;
else if (amount->value() > BitcoinUnits::maxAmount(currentUnit))
valid = false;
setValid(valid); setValid(valid);
return valid; return valid;
} }
@ -129,14 +236,6 @@ void BitcoinAmountField::setValid(bool valid)
amount->setStyleSheet(STYLE_INVALID); amount->setStyleSheet(STYLE_INVALID);
} }
QString BitcoinAmountField::text() const
{
if (amount->text().isEmpty())
return QString();
else
return amount->text();
}
bool BitcoinAmountField::eventFilter(QObject *object, QEvent *event) bool BitcoinAmountField::eventFilter(QObject *object, QEvent *event)
{ {
if (event->type() == QEvent::FocusIn) if (event->type() == QEvent::FocusIn)
@ -144,17 +243,6 @@ bool BitcoinAmountField::eventFilter(QObject *object, QEvent *event)
// Clear invalid flag on focus // Clear invalid flag on focus
setValid(true); setValid(true);
} }
else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease)
{
QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Comma)
{
// Translate a comma into a period
QKeyEvent periodKeyEvent(event->type(), Qt::Key_Period, keyEvent->modifiers(), ".", keyEvent->isAutoRepeat(), keyEvent->count());
QApplication::sendEvent(object, &periodKeyEvent);
return true;
}
}
return QWidget::eventFilter(object, event); return QWidget::eventFilter(object, event);
} }
@ -167,18 +255,12 @@ QWidget *BitcoinAmountField::setupTabChain(QWidget *prev)
qint64 BitcoinAmountField::value(bool *valid_out) const qint64 BitcoinAmountField::value(bool *valid_out) const
{ {
qint64 val_out = 0; return amount->value(valid_out);
bool valid = BitcoinUnits::parse(currentUnit, text(), &val_out);
if (valid_out)
{
*valid_out = valid;
}
return val_out;
} }
void BitcoinAmountField::setValue(qint64 value) void BitcoinAmountField::setValue(qint64 value)
{ {
setText(BitcoinUnits::format(currentUnit, value)); amount->setValue(value);
} }
void BitcoinAmountField::setReadOnly(bool fReadOnly) void BitcoinAmountField::setReadOnly(bool fReadOnly)
@ -195,28 +277,7 @@ void BitcoinAmountField::unitChanged(int idx)
// Determine new unit ID // Determine new unit ID
int newUnit = unit->itemData(idx, BitcoinUnits::UnitRole).toInt(); int newUnit = unit->itemData(idx, BitcoinUnits::UnitRole).toInt();
// Parse current value and convert to new unit amount->setDisplayUnit(newUnit);
bool valid = false;
qint64 currentValue = value(&valid);
currentUnit = newUnit;
// Set max length after retrieving the value, to prevent truncation
amount->setDecimals(BitcoinUnits::decimals(currentUnit));
amount->setMaximum(qPow(10, BitcoinUnits::amountDigits(currentUnit)) - qPow(10, -amount->decimals()));
amount->setSingleStep((double)nSingleStep / (double)BitcoinUnits::factor(currentUnit));
if (valid)
{
// If value was valid, re-place it in the widget with the new unit
setValue(currentValue);
}
else
{
// If current value is invalid, just clear field
setText("");
}
setValid(true);
} }
void BitcoinAmountField::setDisplayUnit(int newUnit) void BitcoinAmountField::setDisplayUnit(int newUnit)
@ -226,6 +287,5 @@ void BitcoinAmountField::setDisplayUnit(int newUnit)
void BitcoinAmountField::setSingleStep(qint64 step) void BitcoinAmountField::setSingleStep(qint64 step)
{ {
nSingleStep = step; amount->setSingleStep(step);
unitChanged(unit->currentIndex());
} }

View File

@ -8,17 +8,18 @@
#include <QWidget> #include <QWidget>
QT_BEGIN_NAMESPACE QT_BEGIN_NAMESPACE
class QDoubleSpinBox;
class QValueComboBox; class QValueComboBox;
QT_END_NAMESPACE QT_END_NAMESPACE
class AmountSpinBox;
/** Widget for entering bitcoin amounts. /** Widget for entering bitcoin amounts.
*/ */
class BitcoinAmountField: public QWidget class BitcoinAmountField: public QWidget
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(qint64 value READ value WRITE setValue NOTIFY textChanged USER true) Q_PROPERTY(qint64 value READ value WRITE setValue NOTIFY valueChanged USER true)
public: public:
explicit BitcoinAmountField(QWidget *parent = 0); explicit BitcoinAmountField(QWidget *parent = 0);
@ -49,20 +50,15 @@ public:
QWidget *setupTabChain(QWidget *prev); QWidget *setupTabChain(QWidget *prev);
signals: signals:
void textChanged(); void valueChanged();
protected: protected:
/** Intercept focus-in event and ',' key presses */ /** Intercept focus-in event and ',' key presses */
bool eventFilter(QObject *object, QEvent *event); bool eventFilter(QObject *object, QEvent *event);
private: private:
QDoubleSpinBox *amount; AmountSpinBox *amount;
QValueComboBox *unit; QValueComboBox *unit;
int currentUnit;
qint64 nSingleStep;
void setText(const QString &text);
QString text() const;
private slots: private slots:
void unitChanged(int idx); void unitChanged(int idx);

View File

@ -4,6 +4,8 @@
#include "bitcoinunits.h" #include "bitcoinunits.h"
#include "core.h"
#include <QStringList> #include <QStringList>
BitcoinUnits::BitcoinUnits(QObject *parent): BitcoinUnits::BitcoinUnits(QObject *parent):
@ -250,3 +252,8 @@ QVariant BitcoinUnits::data(const QModelIndex &index, int role) const
} }
return QVariant(); return QVariant();
} }
qint64 BitcoinUnits::maxMoney()
{
return MAX_MONEY;
}

View File

@ -120,6 +120,9 @@ public:
return text; return text;
} }
//! Return maximum number of base units (Satoshis)
static qint64 maxMoney();
private: private:
QList<BitcoinUnits::Unit> unitlist; QList<BitcoinUnits::Unit> unitlist;
}; };

View File

@ -72,7 +72,7 @@ void SendCoinsEntry::setModel(WalletModel *model)
if (model && model->getOptionsModel()) if (model && model->getOptionsModel())
connect(model->getOptionsModel(), SIGNAL(displayUnitChanged(int)), this, SLOT(updateDisplayUnit())); connect(model->getOptionsModel(), SIGNAL(displayUnitChanged(int)), this, SLOT(updateDisplayUnit()));
connect(ui->payAmount, SIGNAL(textChanged()), this, SIGNAL(payAmountChanged())); connect(ui->payAmount, SIGNAL(valueChanged()), this, SIGNAL(payAmountChanged()));
connect(ui->deleteButton, SIGNAL(clicked()), this, SLOT(deleteClicked())); connect(ui->deleteButton, SIGNAL(clicked()), this, SLOT(deleteClicked()));
connect(ui->deleteButton_is, SIGNAL(clicked()), this, SLOT(deleteClicked())); connect(ui->deleteButton_is, SIGNAL(clicked()), this, SLOT(deleteClicked()));
connect(ui->deleteButton_s, SIGNAL(clicked()), this, SLOT(deleteClicked())); connect(ui->deleteButton_s, SIGNAL(clicked()), this, SLOT(deleteClicked()));
@ -130,6 +130,13 @@ bool SendCoinsEntry::validate()
retval = false; retval = false;
} }
// Sending a zero amount is invalid
if (ui->payAmount->value(0) <= 0)
{
ui->payAmount->setValid(false);
retval = false;
}
// Reject dust outputs: // Reject dust outputs:
if (retval && GUIUtil::isDust(ui->payTo->text(), ui->payAmount->value())) { if (retval && GUIUtil::isDust(ui->payTo->text(), ui->payAmount->value())) {
ui->payAmount->setValid(false); ui->payAmount->setValid(false);