Optimize: Rather than re-style every single table item when there's a new block,...
[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         sf = None
110         if stage:
111             if stage == 0x100:
112                 status = self.tr('Confirmed (%s)')
113             else:
114                 status = self.tr('Processing... (%s)')
115             if self.confirmation_stage(category, status_item.confirmations) < stage:
116                 sf = self.enable_table_item
117         else:
118             status = self.tr('Validating... (%s)')
119             if not hastxt:
120                 sf = self.disable_table_item
121         status %= (format_number(confirms),)
122
123         status_item.setText(status)
124         status_item.confirmations = confirms
125
126         if sf:
127             for j in range(0, 5):
128                 sf(self.item(i, j))
129
130     def update_transaction_time(self, row, unixtime):
131         date_formatter = QDateTime()
132         date_formatter.setTime_t(unixtime)
133         # we need to do this in parts to have a month name translation
134         # datetime = date_formatter.toString('hh:mm d ')
135         # datetime += self.tr(date_formatter.toString('MMM '))
136         # datetime += date_formatter.toString('yy')
137         datetime = date_formatter.toString('hh:mm d MMM yy')
138         date_item = self.item(row, 1)
139         date_item.setText(datetime)
140
141     def add_transaction_entry(self, transaction):
142         self.insertRow(0)
143         confirms = None
144         if 'confirmations' in transaction:
145             confirms = transaction['confirmations']
146         unixtime = transaction['time']
147         address = 'N/A'
148         if 'address' in transaction:
149             address = transaction['address']
150         credit =  transaction['amount']
151         balance = 'N/A'
152         category = transaction['category']
153
154         status_item = TransactionItem('')
155         if confirms is None:
156             status_item.setText('N/A')
157         status_item.confirmations = confirms
158         self.setItem(0, 0, status_item)
159
160         date_item = TransactionItem('')
161         self.setItem(0, 1, date_item)
162         self.update_transaction_time(0, unixtime)
163
164         if category == 'send':
165             description = self.tr('Sent to %s')%address
166         elif category == 'receive':
167             description = self.tr('Received to %s')%address
168         elif category in ('generate', 'immature'):
169             description = self.tr('Generated')
170             status_item.category = category
171         elif category == 'move':
172             description = self.tr('Moved')
173         else:
174             description = self.tr('Unknown')
175         trans_item = TransactionItem(description)
176         self.setItem(0, 2, trans_item)
177
178         credits_item = TransactionItem(humanAmount(credit), Qt.AlignRight)
179         credits_item.amount = credit
180         self.setItem(0, 3, credits_item)
181
182         balance_item = TransactionItem(humanAmount(balance), Qt.AlignRight)
183         self.setItem(0, 4, balance_item)
184
185         self.update_confirmation(0, confirms, adjustment=False)
186
187         return status_item
188
189     def move_row(self, from_row, to_row):
190         items = []
191         CC = self.columnCount()
192         for col in xrange(CC):
193             items.append(self.takeItem(from_row, col))
194         self.removeRow(from_row)
195         self.insertRow(to_row)
196         for col in xrange(CC):
197             self.setItem(to_row, col, items[col])
198
199     def update_amounts(self):
200         for i in xrange(self.rowCount()):
201             credits_item = self.item(i, 3)
202             credits_item.setText(humanAmount(credits_item.amount))
203
204     def update_counters(self):
205         for i in xrange(self.rowCount()):
206             status_item = self.item(i, 0)
207             status_item.setText('')
208             self.update_confirmation(i, status_item.confirmations, adjustment=False)
209
210     def update_confirmations(self, increment, adjustment = True):
211         if increment == 0 and adjustment:
212             return
213         for i in range(0, self.rowCount()):
214             self.update_confirmation(i, increment, adjustment)
215
216     def enable_table_item(self, item):
217         if not hasattr(self, '_eti'):
218             # Must already be enabled :p
219             return
220         style_item(item, self._eti)
221
222     def disable_table_item(self, item):
223         want_old = not hasattr(self, '_eti')
224         rv = disable_item(item, want_old=want_old)
225         if want_old:
226             self._eti = rv
227
228 class Cashier(QDialog):
229     def __init__(self, core, clipboard, parent=None):
230         super(Cashier, self).__init__(parent)
231         self.core = core
232         self.clipboard = clipboard
233
234         self.create_actions()
235         main_layout = QVBoxLayout(self)
236
237         youraddy = QHBoxLayout()
238         # Balance + Send button
239         self.balance_label = QLabel()
240         self.refresh_balance()
241         sendbtn = QToolButton(self)
242         sendbtn.setDefaultAction(self.send_act)
243         # Address + New button + Copy button
244         uraddtext = QLabel(self.tr('Your address:'))
245         self.addy = FocusLineEdit(self.core.default_address())
246         newaddybtn = QToolButton(self)
247         newaddybtn.setDefaultAction(self.newaddy_act)
248         copyaddybtn = QToolButton(self)
249         copyaddybtn.setDefaultAction(self.copyaddy_act)
250         # Add them to the layout
251         youraddy.addWidget(uraddtext)
252         youraddy.addWidget(self.addy)
253         youraddy.addWidget(newaddybtn)
254         youraddy.addWidget(copyaddybtn)
255         youraddy.addStretch()
256         settingsbtn = QToolButton(self)
257         settingsbtn.setDefaultAction(self.settings_act)
258         youraddy.addWidget(settingsbtn)
259         youraddy.addStretch()
260         youraddy.addWidget(self.balance_label)
261         youraddy.addWidget(sendbtn)
262         main_layout.addLayout(youraddy)
263
264         self.transactions_table = TransactionsTable()
265         main_layout.addWidget(self.transactions_table)
266
267         #webview = QWebView()
268         #webview.load('http://bitcoinwatch.com/')
269         #webview.setFixedSize(880, 300)
270         #mf = webview.page().mainFrame()
271         #mf.setScrollBarPolicy(Qt.Horizontal,
272         #                      Qt.ScrollBarAlwaysOff)
273         #mf.setScrollBarPolicy(Qt.Vertical,
274         #                      Qt.ScrollBarAlwaysOff)
275         #main_layout.addWidget(webview)
276
277         caption = self.tr('Spesmilo')
278         if parent is not None:
279             self.setWindowIcon(parent.bitcoin_icon)
280             if parent.caption:
281                 caption = parent.caption
282         self.setWindowTitle(caption)
283         self.setAttribute(Qt.WA_DeleteOnClose, False)
284
285         self.txload_initial = 0x1000
286         self.txload_poll = 8
287         self.txload_waste = 8
288         self._refresh_transactions_debug = []
289         self.transactions_table.confirmation_stage = self.confirmation_stage
290
291         refresh_info_timer = QTimer(self)
292         refresh_info_timer.timeout.connect(self.refresh_info)
293         refresh_info_timer.start(1000)
294         # Stores last transaction added to the table
295         self.last_tx = None
296         self.last_tx_with_confirmations = None
297         # Used for updating number of confirms
298         self.unconfirmed_tx = []
299         #   key=txid, category  val=row, confirms
300         self.trans_lookup = {}
301         self.refresh_info()
302         #self.transactions_table.add_transaction_entry({'confirmations': 3, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
303         #self.transactions_table.add_transaction_entry({'confirmations': 0, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
304
305         self.resize(640, 420)
306
307     def confirmation_stage(self, category, confirms):
308         sch = self.confirmation_stage.sch
309         if category not in sch:
310             category = None
311         sch = sch[category]
312         if confirms < sch[0]:
313             return 0
314         if sch[1] is None or confirms < sch[1]:
315             return 0x80
316         return 0x100
317     confirmation_stage.sch = {}
318     confirmation_stage.sch[None] = (2, 6)
319     confirmation_stage.sch['generate'] = (100, 120)
320     confirmation_stage.sch['immature'] = (100, None)
321
322     def refresh_info(self):
323         self.refresh_balance()
324         self.refresh_transactions()
325
326     def __etxid(self, t):
327         txid = t['txid']
328         category = t['category']
329         if category == 'immature':
330             category = 'generate'
331         etxid = "%s/%s" % (txid, category)
332         return etxid
333
334     def update_amounts(self):
335         self.transactions_table.update_amounts()
336
337     def update_counters(self):
338         self.refresh_balance_label()
339         self.transactions_table.update_counters()
340
341     def refresh_transactions(self):
342         debuglog = []
343         fetchtx = self.txload_initial
344         utx = {}
345         if not self.last_tx is None:
346             # Figure out just how many fetches are needed to comfortably update new unconfirmed tx
347             fetchtx = 0
348             debuglog += [{'raw_unconfirmed_tx': self.unconfirmed_tx}]
349             for etxid, status_item in self.unconfirmed_tx:
350                 row = self.transactions_table.row(status_item)
351                 utx[etxid] = [status_item, None]
352                 debuglog += ["Present unconfirmed tx %s at row %d" % (etxid, row)]
353                 # Allow up to 5 wasted refetches in between unconfirmed refetches
354                 if row <= fetchtx + self.txload_waste:
355                     fetchtx = row + 1
356             fetchtx += self.txload_poll
357         while True:
358             debuglog += ["Fetching %d transactions" % (fetchtx,)]
359             transactions = self.core.transactions('*', fetchtx)
360             debuglog += [{'raw_txlist': transactions}]
361
362             # Sort through fetched transactions, updating confirmation counts
363             ttf = len(transactions)
364             transactions.reverse()
365             otl = []
366             nltwc = None
367             nomore = False
368             petime = time()
369             if self.last_tx:
370                 petime += .001
371             for i in xrange(ttf):
372                 nowtime = time()
373                 if petime < nowtime and self.parent():
374                     self.parent().app.processEvents()
375                     petime = nowtime + .001
376                 t = transactions[i]
377                 if 'txid' not in t:
378                     continue
379                 txid = t['txid'] if 'txid' in t else False
380                 category = t['category']
381                 if 'confirmations' in t:
382                     confirms = t['confirmations']
383                     if nltwc is None and self.confirmation_stage(category, confirms) == 0x100:
384                         nltwc = t
385                         debuglog += ["New last_tx_with_confirmations = %s" % (txid,)]
386                     if txid == self.last_tx_with_confirmations:
387                         ci = confirms - self.last_tx_with_confirmations_n
388                         debuglog += ["Found last_tx_with_confirmations (%s) with %d confirms (+%d)" % (txid, confirms, ci)]
389                         if ci:
390                             self.transactions_table.update_confirmations(ci)
391                         self.last_tx_with_confirmations_n = confirms
392                     etxid = self.__etxid(t)
393                     if etxid in utx:
394                         utx[etxid][1] = (t,)
395                 if nomore:
396                     continue
397                 if txid == self.last_tx:
398                     debuglog += ["Found last recorded tx (%s)" % (txid,)]
399                     nomore = True
400                     if i >= self.txload_poll:
401                         self.txload_poll = i + 1
402                     continue
403                 if category == 'orphan':
404                     continue
405                 otl.append(t)
406             transactions = otl
407
408             if nomore or fetchtx > ttf: break
409
410             # If we get here, that means we didn't fetch enough to see our last confirmed tx... retry, this time getting more
411             fetchtx *= 2
412
413         if not nltwc is None:
414             self.last_tx_with_confirmations = nltwc['txid']
415             self.last_tx_with_confirmations_n = nltwc['confirmations']
416
417         if transactions:
418             transactions.reverse()
419             debuglog += [{'new_txlist': transactions}]
420
421             # Add any new transactions
422             for t in transactions:
423                 etxid = self.__etxid(t)
424                 if etxid in utx:
425                     # When a transaction is goes from 0 to 1 confirmation, bitcoind seems to reset its time and position in the listtransactions list :(
426                     status_item = utx[etxid][0]
427                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
428                     row = self.transactions_table.row(status_item)
429                     unixtime = t['time']
430
431                     self.transactions_table.move_row(row, 0)
432                     self.transactions_table.update_transaction_time(row, unixtime)
433                     continue
434
435                 status_item = self.transactions_table.add_transaction_entry(t)
436                 if 'confirmations' not in t: continue
437                 category = t['category']
438                 confirms = t['confirmations']
439                 if self.confirmation_stage(category, confirms) < 0x100:
440                     self.unconfirmed_tx.insert(0, (etxid, status_item) )
441                     debuglog += ["New unconfirmed tx: %s" % (etxid,)]
442             self.last_tx = transactions[-1]['txid']
443
444         # Finally, fetch individual tx info for any old unconfirmed tx
445         while len(utx):
446             etxid, data = utx.items()[0]
447             status_item, transactions = data
448             txid = etxid[:etxid.index('/')]
449             if transactions is None:
450                 debuglog += ["Specially fetching unconfirmed tx: %s" % (etxid,)]
451                 transactions = self.core.get_transaction(txid)
452             for t in transactions:
453                 etxid = self.__etxid(t)
454                 if etxid in utx:
455                     category = t['category']
456                     confirms = t['confirmations']
457                     txdone = True
458                     status_item = utx[etxid][0]
459                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
460                     row = self.transactions_table.row(status_item)
461                     if category == 'orphan':
462                         debuglog += ["Tx %s (row %d) has been orphaned" % (etxid, row, confirms)]
463                         self.transactions_table.removeRow(row)
464                     else:
465                         debuglog += ["Tx %s (row %d) has %d confirms" % (etxid, row, confirms)]
466                         if category in ('generate', 'immature'):
467                             status_item.category = category
468                         elif hasattr(status_item, 'category'):
469                             del status_item.category
470                         self.transactions_table.update_confirmation(row, confirms, adjustment=False)
471                         if self.confirmation_stage(category, confirms) < 0x100:
472                             txdone = False
473                     del utx[etxid]
474                     if txdone:
475                         for i in xrange(len(self.unconfirmed_tx)):
476                             if self.unconfirmed_tx[i][0] == etxid:
477                                 self.unconfirmed_tx[i:i+1] = ()
478                                 break
479
480         if SpesmiloSettings.debugMode:
481             self._refresh_transactions_debug += [debuglog]
482
483     def refresh_balance(self):
484         self.balance = self.core.balance()
485         self.refresh_balance_label()
486
487     def refresh_balance_label(self):
488         bltext = self.tr('Balance: %s') % (humanAmount(self.balance, wantTLA=True),)
489         self.balance_label.setText(bltext)
490
491     def create_actions(self):
492         self.send_act = QAction(icon('go-next'), self.tr('Send'),
493             self, toolTip=self.tr('Send bitcoins to another person'),
494             triggered=self.new_send_dialog)
495         self.newaddy_act = QAction(icon('document-new'),
496             self.tr('New address'), self,
497             toolTip=self.tr('Create new address for accepting bitcoins'),
498             triggered=self.new_address)
499         self.copyaddy_act = QAction(icon('copy-bitcoin-address', 'klipper', 'tool_clipboard', 'edit-copy', 'icons/edit-copy.png'),
500             self.tr('Copy address'),
501             self, toolTip=self.tr('Copy address to clipboard'),
502             triggered=self.copy_address)
503         self.settings_act = QAction(icon('configure', 'icons/preferences-system.png'),
504             self.tr('Settings'),
505             self, toolTip=self.tr('Configure Spesmilo'),
506             triggered=self.open_settings)
507
508     def new_send_dialog(self):
509         if self.parent() is not None:
510             send_dialog = send.SendDialog(self.core, self.parent())
511         else:
512             send_dialog = send.SendDialog(self.core, self)
513
514     def new_address(self):
515         self.addy.setText(self.core.new_address())
516
517     def copy_address(self):
518         self.clipboard.setText(self.addy.text())
519
520     def open_settings(self):
521         if hasattr(self, 'settingsdlg'):
522             self.settingsdlg.show()
523             self.settingsdlg.setFocus()
524         else:
525             import settings
526             self.settingsdlg = settings.SettingsDialog(self)
527
528 if __name__ == '__main__':
529     import os
530     import sys
531     import core_interface
532     from settings import SpesmiloSettings
533     os.system('/home/genjix/src/bitcoin/bitcoind')
534     app = QApplication(sys.argv)
535     SpesmiloSettings.loadTranslator()
536     uri = SpesmiloSettings.getEffectiveURI()
537     core = core_interface.CoreInterface(uri)
538     clipboard = qApp.clipboard()
539     cashier = Cashier(core, clipboard)
540     cashier.show()
541     sys.exit(app.exec_())