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)
112 status = self.tr('Confirmed (%s)')
114 status = self.tr('Processing... (%s)')
115 if self.confirmation_stage(category, status_item.confirmations) < stage:
116 sf = self.enable_table_item
118 status = self.tr('Validating... (%s)')
120 sf = self.disable_table_item
121 status %= (format_number(confirms),)
123 status_item.setText(status)
124 status_item.confirmations = confirms
127 for j in range(0, 5):
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)
141 def add_transaction_entry(self, transaction):
144 if 'confirmations' in transaction:
145 confirms = transaction['confirmations']
146 unixtime = transaction['time']
148 if 'address' in transaction:
149 address = transaction['address']
150 credit = transaction['amount']
152 category = transaction['category']
154 status_item = TransactionItem('')
156 status_item.setText('N/A')
157 status_item.confirmations = confirms
158 self.setItem(0, 0, status_item)
160 date_item = TransactionItem('')
161 self.setItem(0, 1, date_item)
162 self.update_transaction_time(0, unixtime)
164 if category == 'send':
165 description = self.tr('Sent to %s')%address
166 elif category == 'receive':
168 acct = transaction.get('account', None)
170 fullto = "%s: %s" % (acct, address)
171 description = self.tr('Received to %s') % (fullto,)
172 elif category in ('generate', 'immature'):
173 description = self.tr('Generated')
174 status_item.category = category
175 elif category == 'move':
176 description = self.tr('Moved')
178 description = self.tr('Unknown')
179 trans_item = TransactionItem(description)
180 self.setItem(0, 2, trans_item)
182 credits_item = TransactionItem(humanAmount(credit), Qt.AlignRight)
183 credits_item.amount = credit
184 self.setItem(0, 3, credits_item)
186 balance_item = TransactionItem(humanAmount(balance), Qt.AlignRight)
187 self.setItem(0, 4, balance_item)
189 self.update_confirmation(0, confirms, adjustment=False)
193 def move_row(self, from_row, to_row):
195 CC = self.columnCount()
196 for col in xrange(CC):
197 items.append(self.takeItem(from_row, col))
198 self.removeRow(from_row)
199 self.insertRow(to_row)
200 for col in xrange(CC):
201 self.setItem(to_row, col, items[col])
203 def update_amounts(self):
204 for i in xrange(self.rowCount()):
205 credits_item = self.item(i, 3)
206 credits_item.setText(humanAmount(credits_item.amount))
208 def update_counters(self):
209 for i in xrange(self.rowCount()):
210 status_item = self.item(i, 0)
211 status_item.setText('')
212 self.update_confirmation(i, status_item.confirmations, adjustment=False)
214 def update_confirmations(self, increment, adjustment = True):
215 if increment == 0 and adjustment:
217 for i in range(0, self.rowCount()):
218 self.update_confirmation(i, increment, adjustment)
220 def enable_table_item(self, item):
221 if not hasattr(self, '_eti'):
222 # Must already be enabled :p
224 style_item(item, self._eti)
226 def disable_table_item(self, item):
227 want_old = not hasattr(self, '_eti')
228 rv = disable_item(item, want_old=want_old)
232 class Cashier(QDialog):
233 def __init__(self, core, clipboard, parent=None, tray=None):
234 super(Cashier, self).__init__(parent)
236 self.clipboard = clipboard
239 self.create_actions()
240 main_layout = QVBoxLayout(self)
242 youraddy = QHBoxLayout()
243 # Balance + Send button
244 self.balance_label = QLabel()
245 self.refresh_balance()
246 sendbtn = QToolButton(self)
247 sendbtn.setDefaultAction(self.send_act)
248 # Address + New button + Copy button
249 uraddtext = QLabel(self.tr('Your address:'))
250 self.addy = FocusLineEdit(self.core.default_address())
251 newaddybtn = QToolButton(self)
252 newaddybtn.setDefaultAction(self.newaddy_act)
253 copyaddybtn = QToolButton(self)
254 copyaddybtn.setDefaultAction(self.copyaddy_act)
255 # Add them to the layout
256 youraddy.addWidget(uraddtext)
257 youraddy.addWidget(self.addy)
258 youraddy.addWidget(newaddybtn)
259 youraddy.addWidget(copyaddybtn)
260 youraddy.addStretch()
261 settingsbtn = QToolButton(self)
262 settingsbtn.setDefaultAction(self.settings_act)
263 youraddy.addWidget(settingsbtn)
264 youraddy.addStretch()
265 youraddy.addWidget(self.balance_label)
266 youraddy.addWidget(sendbtn)
267 main_layout.addLayout(youraddy)
269 self.transactions_table = TransactionsTable()
270 main_layout.addWidget(self.transactions_table)
272 #webview = QWebView()
273 #webview.load('http://bitcoinwatch.com/')
274 #webview.setFixedSize(880, 300)
275 #mf = webview.page().mainFrame()
276 #mf.setScrollBarPolicy(Qt.Horizontal,
277 # Qt.ScrollBarAlwaysOff)
278 #mf.setScrollBarPolicy(Qt.Vertical,
279 # Qt.ScrollBarAlwaysOff)
280 #main_layout.addWidget(webview)
282 caption = self.tr('Spesmilo')
283 if parent is not None:
284 self.setWindowIcon(parent.bitcoin_icon)
286 caption = parent.caption
287 self.setWindowTitle(caption)
288 self.setAttribute(Qt.WA_DeleteOnClose, False)
290 self.txload_initial = 0x1000
292 self.txload_waste = 8
293 self._refresh_transactions_debug = []
294 self.transactions_table.confirmation_stage = self.confirmation_stage
296 refresh_info_timer = QTimer(self)
297 refresh_info_timer.timeout.connect(self.refresh_info)
298 refresh_info_timer.start(1000)
299 # Stores last transaction added to the table
301 self.last_tx_with_confirmations = None
302 # Used for updating number of confirms
303 self.unconfirmed_tx = []
304 # key=txid, category val=row, confirms
305 self.trans_lookup = {}
307 #self.transactions_table.add_transaction_entry({'confirmations': 3, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
308 #self.transactions_table.add_transaction_entry({'confirmations': 0, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
310 self.resize(640, 420)
312 def confirmation_stage(self, category, confirms):
313 sch = self.confirmation_stage.sch
314 if category not in sch:
317 if confirms < sch[0]:
319 if sch[1] is None or confirms < sch[1]:
322 confirmation_stage.sch = {}
323 confirmation_stage.sch[None] = (2, 6)
324 confirmation_stage.sch['generate'] = (100, 120)
325 confirmation_stage.sch['immature'] = (100, None)
327 def refresh_info(self):
328 self.refresh_balance()
329 self.refresh_transactions()
331 def __etxid(self, t):
333 category = t['category']
334 if category == 'immature':
335 category = 'generate'
336 etxid = "%s/%s" % (txid, category)
339 def update_amounts(self):
340 self.transactions_table.update_amounts()
342 def update_counters(self):
343 self.refresh_balance_label()
344 self.transactions_table.update_counters()
346 def refresh_transactions(self):
348 fetchtx = self.txload_initial
350 if not self.last_tx is None:
351 # Figure out just how many fetches are needed to comfortably update new unconfirmed tx
353 debuglog += [{'raw_unconfirmed_tx': self.unconfirmed_tx}]
354 for etxid, status_item in self.unconfirmed_tx:
355 row = self.transactions_table.row(status_item)
356 utx[etxid] = [status_item, None]
357 debuglog += ["Present unconfirmed tx %s at row %d" % (etxid, row)]
358 # Allow up to 5 wasted refetches in between unconfirmed refetches
359 if row <= fetchtx + self.txload_waste:
361 fetchtx += self.txload_poll
363 debuglog += ["Fetching %d transactions" % (fetchtx,)]
364 transactions = self.core.transactions('*', fetchtx)
365 debuglog += [{'raw_txlist': transactions}]
367 # Sort through fetched transactions, updating confirmation counts
368 ttf = len(transactions)
369 transactions.reverse()
376 for i in xrange(ttf):
378 if petime < nowtime and self.parent():
379 self.parent().app.processEvents()
380 petime = nowtime + .001
384 txid = t['txid'] if 'txid' in t else False
385 category = t['category']
386 if 'confirmations' in t:
387 confirms = t['confirmations']
388 if nltwc is None and self.confirmation_stage(category, confirms) == 0x100:
390 debuglog += ["New last_tx_with_confirmations = %s" % (txid,)]
391 if txid == self.last_tx_with_confirmations:
392 ci = confirms - self.last_tx_with_confirmations_n
393 debuglog += ["Found last_tx_with_confirmations (%s) with %d confirms (+%d)" % (txid, confirms, ci)]
395 self.transactions_table.update_confirmations(ci)
396 self.last_tx_with_confirmations_n = confirms
397 etxid = self.__etxid(t)
402 if txid == self.last_tx:
403 debuglog += ["Found last recorded tx (%s)" % (txid,)]
405 if i >= self.txload_poll:
406 self.txload_poll = i + 1
408 if category == 'orphan':
413 if nomore or fetchtx > ttf: break
415 # If we get here, that means we didn't fetch enough to see our last confirmed tx... retry, this time getting more
418 if not nltwc is None:
419 self.last_tx_with_confirmations = nltwc['txid']
420 self.last_tx_with_confirmations_n = nltwc['confirmations']
423 transactions.reverse()
424 debuglog += [{'new_txlist': transactions}]
426 # Add any new transactions
427 for t in transactions:
428 etxid = self.__etxid(t)
430 # When a transaction is goes from 0 to 1 confirmation, bitcoind seems to reset its time and position in the listtransactions list :(
431 status_item = utx[etxid][0]
432 # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
433 row = self.transactions_table.row(status_item)
436 self.transactions_table.move_row(row, 0)
437 self.transactions_table.update_transaction_time(row, unixtime)
440 status_item = self.transactions_table.add_transaction_entry(t)
441 if 'confirmations' not in t: continue
442 category = t['category']
443 confirms = t['confirmations']
444 if self.confirmation_stage(category, confirms) < 0x100:
445 self.unconfirmed_tx.insert(0, (etxid, status_item) )
446 debuglog += ["New unconfirmed tx: %s" % (etxid,)]
447 self.last_tx = transactions[-1]['txid']
449 # Finally, fetch individual tx info for any old unconfirmed tx
451 etxid, data = utx.items()[0]
452 status_item, transactions = data
453 txid = etxid[:etxid.index('/')]
454 if transactions is None:
455 debuglog += ["Specially fetching unconfirmed tx: %s" % (etxid,)]
456 transactions = self.core.get_transaction(txid)
457 for t in transactions:
458 etxid = self.__etxid(t)
460 category = t['category']
461 confirms = t['confirmations']
463 status_item = utx[etxid][0]
464 # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
465 row = self.transactions_table.row(status_item)
466 if category == 'orphan':
467 debuglog += ["Tx %s (row %d) has been orphaned" % (etxid, row, confirms)]
468 self.transactions_table.removeRow(row)
470 debuglog += ["Tx %s (row %d) has %d confirms" % (etxid, row, confirms)]
471 if category in ('generate', 'immature'):
472 status_item.category = category
473 elif hasattr(status_item, 'category'):
474 del status_item.category
475 self.transactions_table.update_confirmation(row, confirms, adjustment=False)
476 if self.confirmation_stage(category, confirms) < 0x100:
480 for i in xrange(len(self.unconfirmed_tx)):
481 if self.unconfirmed_tx[i][0] == etxid:
482 self.unconfirmed_tx[i:i+1] = ()
485 if SpesmiloSettings.debugMode:
486 self._refresh_transactions_debug += [debuglog]
488 def refresh_balance(self):
489 self.balance = self.core.balance()
490 self.refresh_balance_label()
492 def refresh_balance_label(self):
493 bltext = self.tr('Balance: %s') % (humanAmount(self.balance, wantTLA=True),)
494 self.balance_label.setText(bltext)
495 if hasattr(self, 'tray') and self.tray:
496 self.tray.setToolTip(bltext)
498 def create_actions(self):
499 self.send_act = QAction(icon('go-next'), self.tr('Send'),
500 self, toolTip=self.tr('Send bitcoins to another person'),
501 triggered=self.new_send_dialog)
502 self.newaddy_act = QAction(icon('document-new'),
503 self.tr('New address'), self,
504 toolTip=self.tr('Create new address for accepting bitcoins'),
505 triggered=self.new_address)
506 self.copyaddy_act = QAction(icon('copy-bitcoin-address', 'klipper', 'tool_clipboard', 'edit-copy', 'icons/edit-copy.png'),
507 self.tr('Copy address'),
508 self, toolTip=self.tr('Copy address to clipboard'),
509 triggered=self.copy_address)
510 self.settings_act = QAction(icon('configure', 'icons/preferences-system.png'),
512 self, toolTip=self.tr('Configure Spesmilo'),
513 triggered=self.open_settings)
515 def new_send_dialog(self):
516 if self.parent() is not None:
517 send_dialog = send.SendDialog(self.core, self.parent())
519 send_dialog = send.SendDialog(self.core, self)
521 def new_address(self):
522 self.addy.setText(self.core.new_address())
524 def copy_address(self):
525 self.clipboard.setText(self.addy.text())
527 def open_settings(self):
528 if hasattr(self, 'settingsdlg'):
529 self.settingsdlg.show()
530 self.settingsdlg.setFocus()
533 self.settingsdlg = settings.SettingsDialog(self)
535 if __name__ == '__main__':
538 import core_interface
539 from settings import SpesmiloSettings
540 os.system('/home/genjix/src/bitcoin/bitcoind')
541 app = QApplication(sys.argv)
542 SpesmiloSettings.loadTranslator()
543 uri = SpesmiloSettings.getEffectiveURI()
544 core = core_interface.CoreInterface(uri)
545 clipboard = qApp.clipboard()
546 cashier = Cashier(core, clipboard)
548 sys.exit(app.exec_())