1 # -*- coding: utf-8 -*-
2 # Spesmilo -- Python Bitcoin user interface
3 # Copyright © 2011 Luke Dashjr
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.
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.
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/>.
19 from PySide.QtCore import *
20 from PySide.QtGui import *
22 from settings import SpesmiloSettings, humanAmount, format_number, icon, style_item, disable_item
24 class FocusLineEdit(QLineEdit):
25 def __init__(self, text):
26 super(FocusLineEdit, self).__init__(text)
27 self.setReadOnly(True)
30 def mousePressEvent(self, event):
31 if event.button() == Qt.LeftButton:
32 self.setCursorPosition(100)
36 super(FocusLineEdit, self).mousePressEvent(event)
38 def focusOutEvent(self, event):
42 sizeh = super(FocusLineEdit, self).sizeHint()
43 FM = self.fontMetrics()
44 aw = [FM.width(L) for L in '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz']
47 sizeh.setWidth(mw * self.maxLength())
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)
56 class TransactionsTable(QTableWidget):
57 # These are the proportions for the various columns
58 hedprops = (0x80, 0x70, 0x150, 0x68, 0)
61 super(TransactionsTable, self).__init__()
63 self.setColumnCount(5)
64 hedlabels = (self.tr('Status'),
66 self.tr('Transactions'),
69 self.setHorizontalHeaderLabels(hedlabels)
70 for i, sz in enumerate(self.hedprops):
71 self.horizontalHeader().resizeSection(i, sz)
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)
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)
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
97 category = status_item.category if hasattr(status_item, 'category') else None
99 if self.confirmation_stage(category, confirms) < 0x100:
101 confirms = confirms + increment
103 if increment == confirms and hastxt:
108 stage = self.confirmation_stage(category, confirms)
110 status = self.tr('Confirmed (%s)')
112 status = self.tr('Processing... (%s)')
114 status = self.tr('Validating... (%s)')
116 status %= (format_number(confirms),)
118 status_item.setText(status)
119 status_item.confirmations = confirms
121 sf = self.disable_table_item if row_disabled else self.enable_table_item
122 for j in range(0, 5):
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)
136 def add_transaction_entry(self, transaction):
139 if 'confirmations' in transaction:
140 confirms = transaction['confirmations']
141 unixtime = transaction['time']
143 if 'address' in transaction:
144 address = transaction['address']
145 credit = transaction['amount']
147 category = transaction['category']
149 status_item = TransactionItem('')
151 status_item.setText('N/A')
152 status_item.confirmations = confirms
153 self.setItem(0, 0, status_item)
155 date_item = TransactionItem('')
156 self.setItem(0, 1, date_item)
157 self.update_transaction_time(0, unixtime)
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')
169 description = self.tr('Unknown')
170 trans_item = TransactionItem(description)
171 self.setItem(0, 2, trans_item)
173 credits_item = TransactionItem(humanAmount(credit), Qt.AlignRight)
174 credits_item.amount = credit
175 self.setItem(0, 3, credits_item)
177 balance_item = TransactionItem(humanAmount(balance), Qt.AlignRight)
178 self.setItem(0, 4, balance_item)
180 self.update_confirmation(0, confirms, adjustment=False)
184 def move_row(self, from_row, to_row):
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])
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))
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)
205 def update_confirmations(self, increment, adjustment = True):
206 if increment == 0 and adjustment:
208 for i in range(0, self.rowCount()):
209 self.update_confirmation(i, increment, adjustment)
211 def enable_table_item(self, item):
212 if not hasattr(self, '_eti'):
213 # Must already be enabled :p
215 style_item(item, self._eti)
217 def disable_table_item(self, item):
218 want_old = not hasattr(self, '_eti')
219 rv = disable_item(item, want_old=want_old)
223 class Cashier(QDialog):
224 def __init__(self, core, clipboard, parent=None):
225 super(Cashier, self).__init__(parent)
227 self.clipboard = clipboard
229 self.create_actions()
230 main_layout = QVBoxLayout(self)
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)
259 self.transactions_table = TransactionsTable()
260 main_layout.addWidget(self.transactions_table)
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)
272 caption = self.tr('Spesmilo')
273 if parent is not None:
274 self.setWindowIcon(parent.bitcoin_icon)
276 caption = parent.caption
277 self.setWindowTitle(caption)
278 self.setAttribute(Qt.WA_DeleteOnClose, False)
280 self.txload_initial = 0x1000
282 self.txload_waste = 8
283 self._refresh_transactions_debug = []
284 self.transactions_table.confirmation_stage = self.confirmation_stage
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
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 = {}
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'})
300 self.resize(640, 420)
302 def confirmation_stage(self, category, confirms):
303 sch = self.confirmation_stage.sch
304 if category not in sch:
307 if confirms < sch[0]:
309 if sch[1] is None or confirms < sch[1]:
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)
317 def refresh_info(self):
318 self.refresh_balance()
319 self.refresh_transactions()
321 def __etxid(self, t):
323 category = t['category']
324 if category == 'immature':
325 category = 'generate'
326 etxid = "%s/%s" % (txid, category)
329 def update_amounts(self):
330 self.transactions_table.update_amounts()
332 def update_counters(self):
333 self.refresh_balance_label()
334 self.transactions_table.update_counters()
336 def refresh_transactions(self):
338 fetchtx = self.txload_initial
340 if not self.last_tx is None:
341 # Figure out just how many fetches are needed to comfortably update new unconfirmed tx
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:
351 fetchtx += self.txload_poll
353 debuglog += ["Fetching %d transactions" % (fetchtx,)]
354 transactions = self.core.transactions('*', fetchtx)
355 debuglog += [{'raw_txlist': transactions}]
357 # Sort through fetched transactions, updating confirmation counts
358 ttf = len(transactions)
359 transactions.reverse()
366 for i in xrange(ttf):
368 if petime < nowtime and self.parent():
369 self.parent().app.processEvents()
370 petime = nowtime + .001
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:
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)]
385 self.transactions_table.update_confirmations(ci)
386 self.last_tx_with_confirmations_n = confirms
387 etxid = self.__etxid(t)
392 if txid == self.last_tx:
393 debuglog += ["Found last recorded tx (%s)" % (txid,)]
395 if i >= self.txload_poll:
396 self.txload_poll = i + 1
398 if category == 'orphan':
403 if nomore or fetchtx > ttf: break
405 # If we get here, that means we didn't fetch enough to see our last confirmed tx... retry, this time getting more
408 if not nltwc is None:
409 self.last_tx_with_confirmations = nltwc['txid']
410 self.last_tx_with_confirmations_n = nltwc['confirmations']
413 transactions.reverse()
414 debuglog += [{'new_txlist': transactions}]
416 # Add any new transactions
417 for t in transactions:
418 etxid = self.__etxid(t)
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)
426 self.transactions_table.move_row(row, 0)
427 self.transactions_table.update_transaction_time(row, unixtime)
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']
439 # Finally, fetch individual tx info for any old unconfirmed tx
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)
450 category = t['category']
451 confirms = t['confirmations']
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)
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:
470 for i in xrange(len(self.unconfirmed_tx)):
471 if self.unconfirmed_tx[i][0] == etxid:
472 self.unconfirmed_tx[i:i+1] = ()
475 if SpesmiloSettings.debugMode:
476 self._refresh_transactions_debug += [debuglog]
478 def refresh_balance(self):
479 self.balance = self.core.balance()
480 self.refresh_balance_label()
482 def refresh_balance_label(self):
483 bltext = self.tr('Balance: %s') % (humanAmount(self.balance, wantTLA=True),)
484 self.balance_label.setText(bltext)
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'),
500 self, toolTip=self.tr('Configure Spesmilo'),
501 triggered=self.open_settings)
503 def new_send_dialog(self):
504 if self.parent() is not None:
505 send_dialog = send.SendDialog(self.core, self.parent())
507 send_dialog = send.SendDialog(self.core, self)
509 def new_address(self):
510 self.addy.setText(self.core.new_address())
512 def copy_address(self):
513 self.clipboard.setText(self.addy.text())
515 def open_settings(self):
516 if hasattr(self, 'settingsdlg'):
517 self.settingsdlg.show()
518 self.settingsdlg.setFocus()
521 self.settingsdlg = settings.SettingsDialog(self)
523 if __name__ == '__main__':
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)
536 sys.exit(app.exec_())