abstract "disabled" style a bit, and only allow selecting Tonal number system if...
[bitcoin:spesmilo.git] / cashier.py
1 # -*- coding: utf-8 -*-
2 # Spesmilo -- Python Bitcoin user interface
3 # Copyright © 2011 Luke Dashjr
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, version 3 only.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 import re
18 from time import time
19 from PySide.QtCore import *
20 from PySide.QtGui import *
21 import send
22 from settings import SpesmiloSettings, humanAmount, format_number, icon, style_item, disable_item
23
24 class FocusLineEdit(QLineEdit):
25     def __init__(self, text):
26         super(FocusLineEdit, self).__init__(text)
27         self.setReadOnly(True)
28         self.setMaxLength(40)
29
30     def mousePressEvent(self, event):
31         if event.button() == Qt.LeftButton:
32             self.setCursorPosition(100)
33             self.selectAll()
34             event.accept()
35         else:
36             super(FocusLineEdit, self).mousePressEvent(event)
37
38     def focusOutEvent(self, event):
39         event.accept()
40
41     def sizeHint(self):
42         sizeh = super(FocusLineEdit, self).sizeHint()
43         FM = self.fontMetrics()
44         aw = [FM.width(L) for L in '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz']
45         aw.sort()
46         mw = aw[30]
47         sizeh.setWidth(mw * self.maxLength())
48         return sizeh
49
50 class TransactionItem(QTableWidgetItem):
51     def __init__(self, text, align=Qt.AlignLeft):
52         super(TransactionItem, self).__init__(text)
53         self.setFlags(Qt.ItemIsEnabled)
54         self.setTextAlignment(align|Qt.AlignVCenter)
55
56 class TransactionsTable(QTableWidget):
57     # These are the proportions for the various columns
58     hedprops = (0x80, 0x70, 0x150, 0x68, 0)
59
60     def __init__(self):
61         super(TransactionsTable, self).__init__()
62
63         self.setColumnCount(5)
64         hedlabels = (self.tr('Status'),
65                      self.tr('Date'),
66                      self.tr('Transactions'),
67                      self.tr('Credits'),
68                      self.tr('Balance'))
69         self.setHorizontalHeaderLabels(hedlabels)
70         for i, sz in enumerate(self.hedprops):
71             self.horizontalHeader().resizeSection(i, sz)
72         self.hideColumn(4)
73
74         self.setSelectionBehavior(self.SelectRows)
75         self.setSelectionMode(self.NoSelection)
76         self.setFocusPolicy(Qt.NoFocus)
77         self.setAlternatingRowColors(True)
78         self.verticalHeader().hide()
79         self.setShowGrid(False)
80         self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
81         self.horizontalHeader().setStretchLastSection(True)
82
83     # Resize columns while maintaining proportions
84     def resizeEvent(self, event):
85         self_width = event.size().width()
86         total_prop_width = sum(self.hedprops)
87         newszs = [sz * self_width / total_prop_width for sz in self.hedprops]
88         for i, sz in enumerate(newszs):
89             self.horizontalHeader().resizeSection(i, sz)
90
91     def update_confirmation(self, i, increment, adjustment = True):
92         status_item = self.item(i, 0)
93         hastxt = len(status_item.text())
94         confirms = status_item.confirmations
95         if confirms is None:
96             return
97         category = status_item.category if hasattr(status_item, 'category') else None
98         if adjustment:
99             if self.confirmation_stage(category, confirms) < 0x100:
100                 return
101             confirms = confirms + increment
102         else:
103             if increment == confirms and hastxt:
104                 return
105             confirms = increment
106
107         row_disabled = False
108         stage = self.confirmation_stage(category, confirms)
109         if stage == 0x100:
110             status = self.tr('Confirmed (%s)')
111         elif stage:
112             status = self.tr('Processing... (%s)')
113         else:
114             status = self.tr('Validating... (%s)')
115             row_disabled = True
116         status %= (format_number(confirms),)
117
118         status_item.setText(status)
119         status_item.confirmations = confirms
120
121         sf = self.disable_table_item if row_disabled else self.enable_table_item
122         for j in range(0, 5):
123             sf(self.item(i, j))
124
125     def update_transaction_time(self, row, unixtime):
126         date_formatter = QDateTime()
127         date_formatter.setTime_t(unixtime)
128         # we need to do this in parts to have a month name translation
129         # datetime = date_formatter.toString('hh:mm d ')
130         # datetime += self.tr(date_formatter.toString('MMM '))
131         # datetime += date_formatter.toString('yy')
132         datetime = date_formatter.toString('hh:mm d MMM yy')
133         date_item = self.item(row, 1)
134         date_item.setText(datetime)
135
136     def add_transaction_entry(self, transaction):
137         self.insertRow(0)
138         confirms = None
139         if 'confirmations' in transaction:
140             confirms = transaction['confirmations']
141         unixtime = transaction['time']
142         address = 'N/A'
143         if 'address' in transaction:
144             address = transaction['address']
145         credit =  transaction['amount']
146         balance = 'N/A'
147         category = transaction['category']
148
149         status_item = TransactionItem('')
150         if confirms is None:
151             status_item.setText('N/A')
152         status_item.confirmations = confirms
153         self.setItem(0, 0, status_item)
154
155         date_item = TransactionItem('')
156         self.setItem(0, 1, date_item)
157         self.update_transaction_time(0, unixtime)
158
159         if category == 'send':
160             description = self.tr('Sent to %s')%address
161         elif category == 'receive':
162             description = self.tr('Received to %s')%address
163         elif category in ('generate', 'immature'):
164             description = self.tr('Generated')
165             status_item.category = category
166         elif category == 'move':
167             description = self.tr('Moved')
168         else:
169             description = self.tr('Unknown')
170         trans_item = TransactionItem(description)
171         self.setItem(0, 2, trans_item)
172
173         credits_item = TransactionItem(humanAmount(credit), Qt.AlignRight)
174         credits_item.amount = credit
175         self.setItem(0, 3, credits_item)
176
177         balance_item = TransactionItem(humanAmount(balance), Qt.AlignRight)
178         self.setItem(0, 4, balance_item)
179
180         self.update_confirmation(0, confirms, adjustment=False)
181
182         return status_item
183
184     def move_row(self, from_row, to_row):
185         items = []
186         CC = self.columnCount()
187         for col in xrange(CC):
188             items.append(self.takeItem(from_row, col))
189         self.removeRow(from_row)
190         self.insertRow(to_row)
191         for col in xrange(CC):
192             self.setItem(to_row, col, items[col])
193
194     def update_amounts(self):
195         for i in xrange(self.rowCount()):
196             credits_item = self.item(i, 3)
197             credits_item.setText(humanAmount(credits_item.amount))
198
199     def update_counters(self):
200         for i in xrange(self.rowCount()):
201             status_item = self.item(i, 0)
202             status_item.setText('')
203             self.update_confirmation(i, status_item.confirmations, adjustment=False)
204
205     def update_confirmations(self, increment, adjustment = True):
206         if increment == 0 and adjustment:
207             return
208         for i in range(0, self.rowCount()):
209             self.update_confirmation(i, increment, adjustment)
210
211     def enable_table_item(self, item):
212         if not hasattr(self, '_eti'):
213             # Must already be enabled :p
214             return
215         style_item(item, self._eti)
216
217     def disable_table_item(self, item):
218         want_old = not hasattr(self, '_eti')
219         rv = disable_item(item, want_old=want_old)
220         if want_old:
221             self._eti = rv
222
223 class Cashier(QDialog):
224     def __init__(self, core, clipboard, parent=None):
225         super(Cashier, self).__init__(parent)
226         self.core = core
227         self.clipboard = clipboard
228
229         self.create_actions()
230         main_layout = QVBoxLayout(self)
231
232         youraddy = QHBoxLayout()
233         # Balance + Send button
234         self.balance_label = QLabel()
235         self.refresh_balance()
236         sendbtn = QToolButton(self)
237         sendbtn.setDefaultAction(self.send_act)
238         # Address + New button + Copy button
239         uraddtext = QLabel(self.tr('Your address:'))
240         self.addy = FocusLineEdit(self.core.default_address())
241         newaddybtn = QToolButton(self)
242         newaddybtn.setDefaultAction(self.newaddy_act)
243         copyaddybtn = QToolButton(self)
244         copyaddybtn.setDefaultAction(self.copyaddy_act)
245         # Add them to the layout
246         youraddy.addWidget(uraddtext)
247         youraddy.addWidget(self.addy)
248         youraddy.addWidget(newaddybtn)
249         youraddy.addWidget(copyaddybtn)
250         youraddy.addStretch()
251         settingsbtn = QToolButton(self)
252         settingsbtn.setDefaultAction(self.settings_act)
253         youraddy.addWidget(settingsbtn)
254         youraddy.addStretch()
255         youraddy.addWidget(self.balance_label)
256         youraddy.addWidget(sendbtn)
257         main_layout.addLayout(youraddy)
258
259         self.transactions_table = TransactionsTable()
260         main_layout.addWidget(self.transactions_table)
261
262         #webview = QWebView()
263         #webview.load('http://bitcoinwatch.com/')
264         #webview.setFixedSize(880, 300)
265         #mf = webview.page().mainFrame()
266         #mf.setScrollBarPolicy(Qt.Horizontal,
267         #                      Qt.ScrollBarAlwaysOff)
268         #mf.setScrollBarPolicy(Qt.Vertical,
269         #                      Qt.ScrollBarAlwaysOff)
270         #main_layout.addWidget(webview)
271
272         caption = self.tr('Spesmilo')
273         if parent is not None:
274             self.setWindowIcon(parent.bitcoin_icon)
275             if parent.caption:
276                 caption = parent.caption
277         self.setWindowTitle(caption)
278         self.setAttribute(Qt.WA_DeleteOnClose, False)
279
280         self.txload_initial = 0x1000
281         self.txload_poll = 8
282         self.txload_waste = 8
283         self._refresh_transactions_debug = []
284         self.transactions_table.confirmation_stage = self.confirmation_stage
285
286         refresh_info_timer = QTimer(self)
287         refresh_info_timer.timeout.connect(self.refresh_info)
288         refresh_info_timer.start(1000)
289         # Stores last transaction added to the table
290         self.last_tx = None
291         self.last_tx_with_confirmations = None
292         # Used for updating number of confirms
293         self.unconfirmed_tx = []
294         #   key=txid, category  val=row, confirms
295         self.trans_lookup = {}
296         self.refresh_info()
297         #self.transactions_table.add_transaction_entry({'confirmations': 3, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
298         #self.transactions_table.add_transaction_entry({'confirmations': 0, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
299
300         self.resize(640, 420)
301
302     def confirmation_stage(self, category, confirms):
303         sch = self.confirmation_stage.sch
304         if category not in sch:
305             category = None
306         sch = sch[category]
307         if confirms < sch[0]:
308             return 0
309         if sch[1] is None or confirms < sch[1]:
310             return 0x80
311         return 0x100
312     confirmation_stage.sch = {}
313     confirmation_stage.sch[None] = (2, 6)
314     confirmation_stage.sch['generate'] = (100, 120)
315     confirmation_stage.sch['immature'] = (100, None)
316
317     def refresh_info(self):
318         self.refresh_balance()
319         self.refresh_transactions()
320
321     def __etxid(self, t):
322         txid = t['txid']
323         category = t['category']
324         if category == 'immature':
325             category = 'generate'
326         etxid = "%s/%s" % (txid, category)
327         return etxid
328
329     def update_amounts(self):
330         self.transactions_table.update_amounts()
331
332     def update_counters(self):
333         self.refresh_balance_label()
334         self.transactions_table.update_counters()
335
336     def refresh_transactions(self):
337         debuglog = []
338         fetchtx = self.txload_initial
339         utx = {}
340         if not self.last_tx is None:
341             # Figure out just how many fetches are needed to comfortably update new unconfirmed tx
342             fetchtx = 0
343             debuglog += [{'raw_unconfirmed_tx': self.unconfirmed_tx}]
344             for etxid, status_item in self.unconfirmed_tx:
345                 row = self.transactions_table.row(status_item)
346                 utx[etxid] = [status_item, None]
347                 debuglog += ["Present unconfirmed tx %s at row %d" % (etxid, row)]
348                 # Allow up to 5 wasted refetches in between unconfirmed refetches
349                 if row <= fetchtx + self.txload_waste:
350                     fetchtx = row + 1
351             fetchtx += self.txload_poll
352         while True:
353             debuglog += ["Fetching %d transactions" % (fetchtx,)]
354             transactions = self.core.transactions('*', fetchtx)
355             debuglog += [{'raw_txlist': transactions}]
356
357             # Sort through fetched transactions, updating confirmation counts
358             ttf = len(transactions)
359             transactions.reverse()
360             otl = []
361             nltwc = None
362             nomore = False
363             petime = time()
364             if self.last_tx:
365                 petime += .001
366             for i in xrange(ttf):
367                 nowtime = time()
368                 if petime < nowtime and self.parent():
369                     self.parent().app.processEvents()
370                     petime = nowtime + .001
371                 t = transactions[i]
372                 if 'txid' not in t:
373                     continue
374                 txid = t['txid'] if 'txid' in t else False
375                 category = t['category']
376                 if 'confirmations' in t:
377                     confirms = t['confirmations']
378                     if nltwc is None and self.confirmation_stage(category, confirms) == 0x100:
379                         nltwc = t
380                         debuglog += ["New last_tx_with_confirmations = %s" % (txid,)]
381                     if txid == self.last_tx_with_confirmations:
382                         ci = confirms - self.last_tx_with_confirmations_n
383                         debuglog += ["Found last_tx_with_confirmations (%s) with %d confirms (+%d)" % (txid, confirms, ci)]
384                         if ci:
385                             self.transactions_table.update_confirmations(ci)
386                         self.last_tx_with_confirmations_n = confirms
387                     etxid = self.__etxid(t)
388                     if etxid in utx:
389                         utx[etxid][1] = (t,)
390                 if nomore:
391                     continue
392                 if txid == self.last_tx:
393                     debuglog += ["Found last recorded tx (%s)" % (txid,)]
394                     nomore = True
395                     if i >= self.txload_poll:
396                         self.txload_poll = i + 1
397                     continue
398                 if category == 'orphan':
399                     continue
400                 otl.append(t)
401             transactions = otl
402
403             if nomore or fetchtx > ttf: break
404
405             # If we get here, that means we didn't fetch enough to see our last confirmed tx... retry, this time getting more
406             fetchtx *= 2
407
408         if not nltwc is None:
409             self.last_tx_with_confirmations = nltwc['txid']
410             self.last_tx_with_confirmations_n = nltwc['confirmations']
411
412         if transactions:
413             transactions.reverse()
414             debuglog += [{'new_txlist': transactions}]
415
416             # Add any new transactions
417             for t in transactions:
418                 etxid = self.__etxid(t)
419                 if etxid in utx:
420                     # When a transaction is goes from 0 to 1 confirmation, bitcoind seems to reset its time and position in the listtransactions list :(
421                     status_item = utx[etxid][0]
422                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
423                     row = self.transactions_table.row(status_item)
424                     unixtime = t['time']
425
426                     self.transactions_table.move_row(row, 0)
427                     self.transactions_table.update_transaction_time(row, unixtime)
428                     continue
429
430                 status_item = self.transactions_table.add_transaction_entry(t)
431                 if 'confirmations' not in t: continue
432                 category = t['category']
433                 confirms = t['confirmations']
434                 if self.confirmation_stage(category, confirms) < 0x100:
435                     self.unconfirmed_tx.insert(0, (etxid, status_item) )
436                     debuglog += ["New unconfirmed tx: %s" % (etxid,)]
437             self.last_tx = transactions[-1]['txid']
438
439         # Finally, fetch individual tx info for any old unconfirmed tx
440         while len(utx):
441             etxid, data = utx.items()[0]
442             status_item, transactions = data
443             txid = etxid[:etxid.index('/')]
444             if transactions is None:
445                 debuglog += ["Specially fetching unconfirmed tx: %s" % (etxid,)]
446                 transactions = self.core.get_transaction(txid)
447             for t in transactions:
448                 etxid = self.__etxid(t)
449                 if etxid in utx:
450                     category = t['category']
451                     confirms = t['confirmations']
452                     txdone = True
453                     status_item = utx[etxid][0]
454                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
455                     row = self.transactions_table.row(status_item)
456                     if category == 'orphan':
457                         debuglog += ["Tx %s (row %d) has been orphaned" % (etxid, row, confirms)]
458                         self.transactions_table.removeRow(row)
459                     else:
460                         debuglog += ["Tx %s (row %d) has %d confirms" % (etxid, row, confirms)]
461                         if category in ('generate', 'immature'):
462                             status_item.category = category
463                         elif hasattr(status_item, 'category'):
464                             del status_item.category
465                         self.transactions_table.update_confirmation(row, confirms, adjustment=False)
466                         if self.confirmation_stage(category, confirms) < 0x100:
467                             txdone = False
468                     del utx[etxid]
469                     if txdone:
470                         for i in xrange(len(self.unconfirmed_tx)):
471                             if self.unconfirmed_tx[i][0] == etxid:
472                                 self.unconfirmed_tx[i:i+1] = ()
473                                 break
474
475         if SpesmiloSettings.debugMode:
476             self._refresh_transactions_debug += [debuglog]
477
478     def refresh_balance(self):
479         self.balance = self.core.balance()
480         self.refresh_balance_label()
481
482     def refresh_balance_label(self):
483         bltext = self.tr('Balance: %s') % (humanAmount(self.balance, wantTLA=True),)
484         self.balance_label.setText(bltext)
485
486     def create_actions(self):
487         self.send_act = QAction(icon('go-next'), self.tr('Send'),
488             self, toolTip=self.tr('Send bitcoins to another person'),
489             triggered=self.new_send_dialog)
490         self.newaddy_act = QAction(icon('document-new'),
491             self.tr('New address'), self,
492             toolTip=self.tr('Create new address for accepting bitcoins'),
493             triggered=self.new_address)
494         self.copyaddy_act = QAction(icon('copy-bitcoin-address', 'klipper', 'tool_clipboard', 'edit-copy', 'icons/edit-copy.png'),
495             self.tr('Copy address'),
496             self, toolTip=self.tr('Copy address to clipboard'),
497             triggered=self.copy_address)
498         self.settings_act = QAction(icon('configure', 'icons/preferences-system.png'),
499             self.tr('Settings'),
500             self, toolTip=self.tr('Configure Spesmilo'),
501             triggered=self.open_settings)
502
503     def new_send_dialog(self):
504         if self.parent() is not None:
505             send_dialog = send.SendDialog(self.core, self.parent())
506         else:
507             send_dialog = send.SendDialog(self.core, self)
508
509     def new_address(self):
510         self.addy.setText(self.core.new_address())
511
512     def copy_address(self):
513         self.clipboard.setText(self.addy.text())
514
515     def open_settings(self):
516         if hasattr(self, 'settingsdlg'):
517             self.settingsdlg.show()
518             self.settingsdlg.setFocus()
519         else:
520             import settings
521             self.settingsdlg = settings.SettingsDialog(self)
522
523 if __name__ == '__main__':
524     import os
525     import sys
526     import core_interface
527     from settings import SpesmiloSettings
528     os.system('/home/genjix/src/bitcoin/bitcoind')
529     app = QApplication(sys.argv)
530     SpesmiloSettings.loadTranslator()
531     uri = SpesmiloSettings.getEffectiveURI()
532     core = core_interface.CoreInterface(uri)
533     clipboard = qApp.clipboard()
534     cashier = Cashier(core, clipboard)
535     cashier.show()
536     sys.exit(app.exec_())