From 2f6283ea39b7d12f93530519b74f61b17ff0224b Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 26 Jan 2011 22:18:18 +0100 Subject: [PATCH 01/55] initial checkin of new console ui pretty alpha code, but it works and gives an idea of the direction the ui might go --- deluge/ui/console/colors.py | 7 +- deluge/ui/console/main.py | 6 +- deluge/ui/console/modes/__init__.py | 0 deluge/ui/console/modes/add_util.py | 71 +++ deluge/ui/console/modes/alltorrents.py | 602 +++++++++++++++++++++++++ deluge/ui/console/modes/basemode.py | 224 +++++++++ deluge/ui/console/modes/input_popup.py | 244 ++++++++++ deluge/ui/console/modes/popup.py | 254 +++++++++++ 8 files changed, 1405 insertions(+), 3 deletions(-) create mode 100644 deluge/ui/console/modes/__init__.py create mode 100644 deluge/ui/console/modes/add_util.py create mode 100644 deluge/ui/console/modes/alltorrents.py create mode 100644 deluge/ui/console/modes/basemode.py create mode 100644 deluge/ui/console/modes/input_popup.py create mode 100644 deluge/ui/console/modes/popup.py diff --git a/deluge/ui/console/colors.py b/deluge/ui/console/colors.py index 83465737d..7d7944b3c 100644 --- a/deluge/ui/console/colors.py +++ b/deluge/ui/console/colors.py @@ -61,7 +61,12 @@ schemes = { "info": ("white", "black", "bold"), "error": ("red", "black", "bold"), "success": ("green", "black", "bold"), - "event": ("magenta", "black", "bold") + "event": ("magenta", "black", "bold"), + "selected": ("black", "white", "bold"), + "marked": ("white","blue","bold"), + "selectedmarked": ("blue","white","bold"), + "header": ("green","black","bold"), + "filterstatus": ("green", "blue", "bold") } # Colors for various torrent states diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index 6ea3adb79..a0090ef15 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -213,7 +213,8 @@ class ConsoleUI(component.Component): # We want to do an interactive session, so start up the curses screen and # pass it the function that handles commands colors.init_colors() - self.screen = screen.Screen(stdscr, self.do_command, self.tab_completer, self.encoding) + from modes.alltorrents import AllTorrents + self.screen = AllTorrents(stdscr, self.coreconfig, self.encoding) self.statusbars = StatusBars() self.eventlog = EventLog() @@ -271,7 +272,8 @@ class ConsoleUI(component.Component): """ if self.interactive: - self.screen.add_line(line, not self.batch_write) + #self.screen.add_line(line, not self.batch_write) + pass else: print(colors.strip_colors(line)) diff --git a/deluge/ui/console/modes/__init__.py b/deluge/ui/console/modes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/deluge/ui/console/modes/add_util.py b/deluge/ui/console/modes/add_util.py new file mode 100644 index 000000000..d9ced794b --- /dev/null +++ b/deluge/ui/console/modes/add_util.py @@ -0,0 +1,71 @@ +# +# add_util.py +# +# Copyright (C) 2011 Nick Lanham +# +# Modified function from commands/add.py: +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# +from twisted.internet import defer + +from deluge.ui.client import client +import deluge.component as component + +from optparse import make_option +import os +import base64 + +def add_torrent(t_file, options, success_cb, fail_cb): + t_options = {} + if options["path"]: + t_options["download_location"] = os.path.expanduser(options["path"]) + + # Keep a list of deferreds to make a DeferredList + if not os.path.exists(t_file): + fail_cb("{!error!}%s doesn't exist!" % t_file) + return + if not os.path.isfile(t_file): + fail_cb("{!error!}%s is a directory!" % t_file) + return + + + filename = os.path.split(t_file)[-1] + filedump = base64.encodestring(open(t_file).read()) + + def on_success(result): + success_cb("{!success!}Torrent added!") + def on_fail(result): + fail_cb("{!error!}Torrent was not added! %s" % result) + + client.core.add_torrent_file(filename, filedump, t_options).addCallback(on_success).addErrback(on_fail) + diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py new file mode 100644 index 000000000..dbd50676e --- /dev/null +++ b/deluge/ui/console/modes/alltorrents.py @@ -0,0 +1,602 @@ +# -*- coding: utf-8 -*- +# +# alltorrens.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +import deluge.component as component +from basemode import BaseMode +import deluge.common +from deluge.ui.client import client + +from collections import deque + +from deluge.ui.sessionproxy import SessionProxy + +from popup import Popup,SelectablePopup,MessagePopup +from add_util import add_torrent +from input_popup import InputPopup + + +try: + import curses +except ImportError: + pass + +import logging +log = logging.getLogger(__name__) + + +# Big help string that gets displayed when the user hits 'h' +HELP_STR = \ +"""This screen shows an overview of the current torrents Deluge is managing. +The currently selected torrent is indicated by having a white background. +You can change the selected torrent using the up/down arrows or the +PgUp/Pg keys. + +Operations can be performed on multiple torrents by marking them and +then hitting Enter. See below for the keys used to mark torrents. + +You can scroll a popup window that doesn't fit its content (like +this one) using the up/down arrows. + +All popup windows can be closed/canceled by hitting the Esc key +(you might need to wait a second for an Esc to register) + +The actions you can perform and the keys to perform them are as follows: + +'a' - Add a torrent + +'h' - Show this help + +'f' - Show only torrents in a certain state + (Will open a popup where you can select the state you want to see) + +'i' - Show more detailed information about the current selected torrent + +'Q' - quit + +'m' - Mark a torrent +'M' - Mark all torrents between currently selected torrent + and last marked torrent +'c' - Un-mark all torrents + +Enter - Show torrent actions popup. Here you can do things like + pause/resume, remove, recheck and so one. These actions + apply to all currently marked torrents. The currently + selected torrent is automatically marked when you press enter. + +'q'/Esc - Close a popup +""" +HELP_LINES = HELP_STR.split('\n') + +class ACTION: + PAUSE=0 + RESUME=1 + REANNOUNCE=2 + EDIT_TRACKERS=3 + RECHECK=4 + REMOVE=5 + +class FILTER: + ALL=0 + ACTIVE=1 + DOWNLOADING=2 + SEEDING=3 + PAUSED=4 + CHECKING=5 + ERROR=6 + QUEUED=7 + +class StateUpdater(component.Component): + def __init__(self, cb, sf,tcb): + component.Component.__init__(self, "AllTorrentsStateUpdater", 1, depend=["SessionProxy"]) + self._status_cb = cb + self._status_fields = sf + self.status_dict = {} + self._torrent_cb = tcb + self._torrent_to_update = None + + def set_torrent_to_update(self, tid, keys): + self._torrent_to_update = tid + self._torrent_keys = keys + + def start(self): + component.get("SessionProxy").get_torrents_status(self.status_dict, self._status_fields).addCallback(self._on_torrents_status,False) + + def update(self): + component.get("SessionProxy").get_torrents_status(self.status_dict, self._status_fields).addCallback(self._on_torrents_status,True) + if self._torrent_to_update: + component.get("SessionProxy").get_torrent_status(self._torrent_to_update, self._torrent_keys).addCallback(self._torrent_cb) + + def _on_torrents_status(self, state, refresh): + self._status_cb(state,refresh) + + +class AllTorrents(BaseMode, component.Component): + def __init__(self, stdscr, coreconfig, encoding=None): + self.curstate = None + self.cursel = 1 + self.curoff = 1 + self.column_string = "" + self.popup = None + self.messages = deque() + self.marked = [] + self.last_mark = -1 + self._sorted_ids = None + self._go_top = False + + self._curr_filter = None + + self.coreconfig = coreconfig + + BaseMode.__init__(self, stdscr, encoding) + curses.curs_set(0) + self.stdscr.notimeout(0) + self.sessionproxy = SessionProxy() + + self._status_fields = ["queue","name","total_wanted","state","progress","num_seeds","total_seeds", + "num_peers","total_peers","download_payload_rate", "upload_payload_rate"] + + self.updater = StateUpdater(self.set_state,self._status_fields,self._on_torrent_status) + + self.column_names = ["#", "Name","Size","State","Progress","Seeders","Peers","Down Speed","Up Speed"] + self._update_columns() + + + self._info_fields = [ + ("Name",None,("name",)), + ("State", None, ("state",)), + ("Down Speed", self._format_speed, ("download_payload_rate",)), + ("Up Speed", self._format_speed, ("upload_payload_rate",)), + ("Progress", self._format_progress, ("progress",)), + ("ETA", deluge.common.ftime, ("eta",)), + ("Path", None, ("save_path",)), + ("Downloaded",deluge.common.fsize,("all_time_download",)), + ("Uploaded", deluge.common.fsize,("total_uploaded",)), + ("Share Ratio", lambda x:x < 0 and "∞" or "%.3f"%x, ("ratio",)), + ("Seeders",self._format_seeds_peers,("num_seeds","total_seeds")), + ("Peers",self._format_seeds_peers,("num_peers","total_peers")), + ("Active Time",deluge.common.ftime,("active_time",)), + ("Seeding Time",deluge.common.ftime,("seeding_time",)), + ("Date Added",deluge.common.fdate,("time_added",)), + ("Availability", lambda x:x < 0 and "∞" or "%.3f"%x, ("distributed_copies",)), + ("Pieces", self._format_pieces, ("num_pieces","piece_length")), + ] + + self._status_keys = ["name","state","download_payload_rate","upload_payload_rate", + "progress","eta","all_time_download","total_uploaded", "ratio", + "num_seeds","total_seeds","num_peers","total_peers", "active_time", + "seeding_time","time_added","distributed_copies", "num_pieces", + "piece_length","save_path"] + + def _update_columns(self): + self.column_widths = [5,-1,15,13,10,10,10,15,15] + req = sum(filter(lambda x:x >= 0,self.column_widths)) + if (req > self.cols): # can't satisfy requests, just spread out evenly + cw = int(self.cols/len(self.column_names)) + for i in range(0,len(self.column_widths)): + self.column_widths[i] = cw + else: + rem = self.cols - req + var_cols = len(filter(lambda x: x < 0,self.column_widths)) + vw = int(rem/var_cols) + for i in range(0, len(self.column_widths)): + if (self.column_widths[i] < 0): + self.column_widths[i] = vw + + self.column_string = "{!header!}%s"%("".join(["%s%s"%(self.column_names[i]," "*(self.column_widths[i]-len(self.column_names[i]))) for i in range(0,len(self.column_names))])) + + def _trim_string(self, string, w): + return "%s... "%(string[0:w-4]) + + def _format_column(self, col, lim): + size = len(col) + if (size >= lim - 1): + return self._trim_string(col,lim) + else: + return "%s%s"%(col," "*(lim-size)) + + def _format_row(self, row): + return "".join([self._format_column(row[i],self.column_widths[i]) for i in range(0,len(row))]) + + def set_state(self, state, refresh): + self.curstate = state + self.numtorrents = len(state) + if refresh: + self.refresh() + + def _scroll_up(self, by): + self.cursel = max(self.cursel - by,1) + if ((self.cursel - 1) < self.curoff): + self.curoff = max(self.cursel - 1,1) + + def _scroll_down(self, by): + self.cursel = min(self.cursel + by,self.numtorrents) + if ((self.curoff + self.rows - 5) < self.cursel): + self.curoff = self.cursel - self.rows + 5 + + def _current_torrent_id(self): + if self._sorted_ids: + return self._sorted_ids[self.cursel-1] + else: + return None + + def _selected_torrent_ids(self): + ret = [] + for i in self.marked: + ret.append(self._sorted_ids[i-1]) + return ret + + def _on_torrent_status(self, state): + if (self.popup): + self.popup.clear() + name = state["name"] + off = int((self.cols/4)-(len(name)/2)) + self.popup.set_title(name) + for i,f in enumerate(self._info_fields): + if f[1] != None: + args = [] + try: + for key in f[2]: + args.append(state[key]) + except: + log.debug("Could not get info field: %s",e) + continue + info = f[1](*args) + else: + info = state[f[2][0]] + + self.popup.add_line("{!info!}%s: {!input!}%s"%(f[0],info)) + self.refresh() + else: + self.updater.set_torrent_to_update(None,None) + + + def on_resize(self, *args): + BaseMode.on_resize_norefresh(self, *args) + self._update_columns() + if self.popup: + self.popup.handle_resize() + self.refresh() + + def _queue_sort(self, v1, v2): + if v1 == v2: + return 0 + if v2 < 0: + return -1 + if v1 < 0: + return 1 + if v1 > v2: + return 1 + if v2 > v1: + return -1 + + def _sort_torrents(self, state): + "sorts by queue #" + return sorted(state,cmp=self._queue_sort,key=lambda s:state.get(s)["queue"]) + + def _format_speed(self, speed): + if (speed > 0): + return deluge.common.fspeed(speed) + else: + return "-" + + def _format_queue(self, qnum): + if (qnum >= 0): + return "%d"%(qnum+1) + else: + return "" + + def _format_seeds_peers(self, num, total): + return "%d (%d)"%(num,total) + + def _format_pieces(self, num, size): + return "%d (%s)"%(num,deluge.common.fsize(size)) + + def _format_progress(self, perc): + return "%.2f%%"%perc + + def _action_error(self, error): + rerr = error.value + self.report_message("An Error Occurred","%s got error %s: %s"%(rerr.method,rerr.exception_type,rerr.exception_msg)) + self.refresh() + + def _torrent_action(self, idx, data): + ids = self._selected_torrent_ids() + if ids: + if data==ACTION.PAUSE: + log.debug("Pausing torrents: %s",ids) + client.core.pause_torrent(ids).addErrback(self._action_error) + elif data==ACTION.RESUME: + log.debug("Resuming torrents: %s", ids) + client.core.resume_torrent(ids).addErrback(self._action_error) + elif data==ACTION.REMOVE: + log.error("Can't remove just yet") + elif data==ACTION.RECHECK: + log.debug("Rechecking torrents: %s", ids) + client.core.force_recheck(ids).addErrback(self._action_error) + elif data==ACTION.REANNOUNCE: + log.debug("Reannouncing torrents: %s",ids) + client.core.force_reannounce(ids).addErrback(self._action_error) + if len(ids) == 1: + self.marked = [] + self.last_mark = -1 + + def _show_torrent_actions_popup(self): + #cid = self._current_torrent_id() + if len(self.marked): + self.popup = SelectablePopup(self,"Torrent Actions",self._torrent_action) + self.popup.add_line("Pause",data=ACTION.PAUSE) + self.popup.add_line("Resume",data=ACTION.RESUME) + self.popup.add_divider() + self.popup.add_line("Update Tracker",data=ACTION.REANNOUNCE) + self.popup.add_divider() + self.popup.add_line("Remove Torrent",data=ACTION.REMOVE) + self.popup.add_line("Force Recheck",data=ACTION.RECHECK) + + def _torrent_filter(self, idx, data): + if data==FILTER.ALL: + self.updater.status_dict = {} + self._curr_filter = None + elif data==FILTER.ACTIVE: + self.updater.status_dict = {"state":"Active"} + self._curr_filter = "Active" + elif data==FILTER.DOWNLOADING: + self.updater.status_dict = {"state":"Downloading"} + self._curr_filter = "Downloading" + elif data==FILTER.SEEDING: + self.updater.status_dict = {"state":"Seeding"} + self._curr_filter = "Seeding" + elif data==FILTER.PAUSED: + self.updater.status_dict = {"state":"Paused"} + self._curr_filter = "Paused" + elif data==FILTER.CHECKING: + self.updater.status_dict = {"state":"Checking"} + self._curr_filter = "Checking" + elif data==FILTER.ERROR: + self.updater.status_dict = {"state":"Error"} + self._curr_filter = "Error" + elif data==FILTER.QUEUED: + self.updater.status_dict = {"state":"Queued"} + self._curr_filter = "Queued" + self._go_top = True + + def _show_torrent_filter_popup(self): + self.popup = SelectablePopup(self,"Filter Torrents",self._torrent_filter) + self.popup.add_line("All",data=FILTER.ALL) + self.popup.add_line("Active",data=FILTER.ACTIVE) + self.popup.add_line("Downloading",data=FILTER.DOWNLOADING,foreground="green") + self.popup.add_line("Seeding",data=FILTER.SEEDING,foreground="blue") + self.popup.add_line("Paused",data=FILTER.PAUSED) + self.popup.add_line("Error",data=FILTER.ERROR,foreground="red") + self.popup.add_line("Checking",data=FILTER.CHECKING,foreground="cyan") + self.popup.add_line("Queued",data=FILTER.QUEUED,foreground="yellow") + + def _do_add(self, result): + log.debug("Doing adding %s (dl to %s)",result["file"],result["path"]) + def suc_cb(msg): + self.report_message("Torrent Added",msg) + def fail_cb(msg): + self.report_message("Failed To Add Torrent",msg) + add_torrent(result["file"],result,suc_cb,fail_cb) + + def _show_torrent_add_popup(self): + dl = "" + try: + dl = self.coreconfig["download_location"] + except KeyError: + pass + self.popup = InputPopup(self,"Add Torrent (Esc to cancel)",close_cb=self._do_add) + self.popup.add_text_input("Enter path to torrent file:","file") + self.popup.add_text_input("Enter save path:","path",dl) + + def report_message(self,title,message): + self.messages.append((title,message)) + + def refresh(self): + # Something has requested we scroll to the top of the list + if self._go_top: + self.cursel = 1 + self.curoff = 1 + self._go_top = False + + # show a message popup if there's anything queued + if self.popup == None and self.messages: + title,msg = self.messages.popleft() + self.popup = MessagePopup(self,title,msg) + + self.stdscr.clear() + + # Update the status bars + if self._curr_filter == None: + self.add_string(0,self.topbar) + else: + self.add_string(0,"%s {!filterstatus!}Current filter: %s"%(self.topbar,self._curr_filter)) + self.add_string(1,self.column_string) + self.add_string(self.rows - 1, self.bottombar) + + # add all the torrents + if self.curstate == {}: + msg = "No torrents match filter".center(self.cols) + self.add_string(3, "{!info!}%s"%msg) + elif self.curstate != None: + tidx = 1 + currow = 2 + self._sorted_ids = self._sort_torrents(self.curstate) + for torrent_id in self._sorted_ids: + if (tidx < self.curoff): + tidx += 1 + continue + ts = self.curstate[torrent_id] + s = self._format_row([self._format_queue(ts["queue"]), + ts["name"], + "%s"%deluge.common.fsize(ts["total_wanted"]), + ts["state"], + self._format_progress(ts["progress"]), + self._format_seeds_peers(ts["num_seeds"],ts["total_seeds"]), + self._format_seeds_peers(ts["num_peers"],ts["total_peers"]), + self._format_speed(ts["download_payload_rate"]), + self._format_speed(ts["upload_payload_rate"]) + ]) + + # default style + fg = "white" + bg = "black" + attr = None + + if tidx in self.marked: + bg = "blue" + attr = "bold" + + if tidx == self.cursel: + bg = "white" + attr = "bold" + if tidx in self.marked: + fg = "blue" + else: + fg = "black" + + if ts["state"] == "Downloading": + fg = "green" + elif ts["state"] == "Seeding": + fg = "blue" + elif ts["state"] == "Error": + fg = "red" + elif ts["state"] == "Queued": + fg = "yellow" + elif ts["state"] == "Checking": + fg = "cyan" + + if attr: + colorstr = "{!%s,%s,%s!}"%(fg,bg,attr) + else: + colorstr = "{!%s,%s!}"%(fg,bg) + self.add_string(currow,"%s%s"%(colorstr,s)) + tidx += 1 + currow += 1 + if (currow > (self.rows - 2)): + break + else: + self.add_string(1, "Waiting for torrents from core...") + + self.stdscr.redrawwin() + self.stdscr.noutrefresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + + def _mark_unmark(self,idx): + if idx in self.marked: + self.marked.remove(idx) + self.last_mark = -1 + else: + self.marked.append(idx) + self.last_mark = idx + + + def _doRead(self): + # Read the character + c = self.stdscr.getch() + + if self.popup: + if self.popup.handle_read(c): + self.popup = None + self.refresh() + return + + if c > 31 and c < 256: + if chr(c) == 'Q': + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + + if self.curstate==None or self.popup: + return + + #log.error("pressed key: %d\n",c) + #if c == 27: # handle escape + # log.error("CANCEL") + + # Navigate the torrent list + if c == curses.KEY_UP: + self._scroll_up(1) + elif c == curses.KEY_PPAGE: + self._scroll_up(int(self.rows/2)) + elif c == curses.KEY_DOWN: + self._scroll_down(1) + elif c == curses.KEY_NPAGE: + self._scroll_down(int(self.rows/2)) + + # Enter Key + elif c == curses.KEY_ENTER or c == 10: + self.marked.append(self.cursel) + self.last_mark = self.cursel + self._show_torrent_actions_popup() + + else: + if c > 31 and c < 256: + if chr(c) == 'i': + cid = self._current_torrent_id() + if cid: + self.popup = Popup(self,"Info",close_cb=lambda:self.updater.set_torrent_to_update(None,None)) + self.popup.add_line("Getting torrent info...") + self.updater.set_torrent_to_update(cid,self._status_keys) + elif chr(c) == 'm': + self._mark_unmark(self.cursel) + elif chr(c) == 'M': + if self.last_mark >= 0: + self.marked.extend(range(self.last_mark,self.cursel+1)) + else: + self._mark_unmark(self.cursel) + elif chr(c) == 'c': + self.marked = [] + self.last_mark = -1 + elif chr(c) == 'a': + self._show_torrent_add_popup() + elif chr(c) == 'f': + self._show_torrent_filter_popup() + elif chr(c) == 'h': + self.popup = Popup(self,"Help") + for l in HELP_LINES: + self.popup.add_line(l) + + self.refresh() diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py new file mode 100644 index 000000000..d02b2d25d --- /dev/null +++ b/deluge/ui/console/modes/basemode.py @@ -0,0 +1,224 @@ +# +# basemode.py +# +# Copyright (C) 2011 Nick Lanham +# +# Most code in this file taken from screen.py: +# Copyright (C) 2009 Andrew Resch +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +import sys +import logging +try: + import curses +except ImportError: + pass + +import deluge.ui.console.colors as colors +try: + import signal + from fcntl import ioctl + import termios + import struct +except: + pass + +from twisted.internet import reactor + +log = logging.getLogger(__name__) + +class CursesStdIO(object): + """fake fd to be registered as a reader with the twisted reactor. + Curses classes needing input should extend this""" + + def fileno(self): + """ We want to select on FD 0 """ + return 0 + + def doRead(self): + """called when input is ready""" + pass + def logPrefix(self): return 'CursesClient' + + +class BaseMode(CursesStdIO): + def __init__(self, stdscr, encoding=None): + """ + A mode that provides a curses screen designed to run as a reader in a twisted reactor. + This mode doesn't do much, just shows status bars and "Base Mode" on the screen + + Modes should subclass this and provide overrides for: + + _doRead(self) - Handle user input + refresh(self) - draw the mode to the screen + add_string(self, row, string) - add a string of text to be displayed. + see method for detailed info + + The init method of a subclass *must* call BaseMode.__init__ + + Useful fields after calling BaseMode.__init__: + self.stdscr - the curses screen + self.rows - # of rows on the curses screen + self.cols - # of cols on the curses screen + self.topbar - top statusbar + self.bottombar - bottom statusbar + """ + log.debug("BaseMode init!") + self.stdscr = stdscr + # Make the input calls non-blocking + self.stdscr.nodelay(1) + + # Strings for the 2 status bars + self.topbar = "" + self.bottombar = "" + + # Keep track of the screen size + self.rows, self.cols = self.stdscr.getmaxyx() + try: + signal.signal(signal.SIGWINCH, self.on_resize) + except Exception, e: + log.debug("Unable to catch SIGWINCH signal!") + + if not encoding: + self.encoding = sys.getdefaultencoding() + else: + self.encoding = encoding + + colors.init_colors() + + # Do a refresh right away to draw the screen + self.refresh() + + def on_resize_norefresh(self, *args): + log.debug("on_resize_from_signal") + # Get the new rows and cols value + self.rows, self.cols = struct.unpack("hhhh", ioctl(0, termios.TIOCGWINSZ ,"\000"*8))[0:2] + curses.resizeterm(self.rows, self.cols) + + def on_resize(self, *args): + self.on_resize_norefresh(args) + self.refresh() + + def connectionLost(self, reason): + self.close() + + def add_string(self, row, string, scr=None, col = 0, pad=True, trim=True): + """ + Adds a string to the desired `:param:row`. + + :param row: int, the row number to write the string + :param string: string, the string of text to add + :param scr: curses.window, optional window to add string to instead of self.stdscr + :param col: int, optional starting column offset + :param pad: bool, optional bool if the string should be padded out to the width of the screen + :param trim: bool, optional bool if the string should be trimmed if it is too wide for the screen + + The text can be formatted with color using the following format: + + "{!fg, bg, attributes, ...!}" + + See: http://docs.python.org/library/curses.html#constants for attributes. + + Alternatively, it can use some built-in scheme for coloring. + See colors.py for built-in schemes. + + "{!scheme!}" + + Examples: + + "{!blue, black, bold!}My Text is {!white, black!}cool" + "{!info!}I am some info text!" + "{!error!}Uh oh!" + + + """ + if scr: + screen = scr + else: + screen = self.stdscr + try: + parsed = colors.parse_color_string(string, self.encoding) + except colors.BadColorString, e: + log.error("Cannot add bad color string %s: %s", string, e) + return + + for index, (color, s) in enumerate(parsed): + if index + 1 == len(parsed) and pad: + # This is the last string so lets append some " " to it + s += " " * (self.cols - (col + len(s)) - 1) + if trim: + y,x = screen.getmaxyx() + if (col+len(s)) > x: + s = "%s..."%s[0:x-4-col] + screen.addstr(row, col, s, color) + col += len(s) + + def refresh(self): + """ + Refreshes the screen. + Updates the lines based on the`:attr:lines` based on the `:attr:display_lines_offset` + attribute and the status bars. + """ + self.stdscr.clear() + + # Update the status bars + self.add_string(0, self.topbar) + self.add_string(self.rows - 1, self.bottombar) + self.add_string(1,"{!info!}Base Mode (or subclass hasn't overridden refresh)") + + self.stdscr.redrawwin() + self.stdscr.refresh() + + def doRead(self): + """ + Called when there is data to be read, ie, input from the keyboard. + """ + # We wrap this function to catch exceptions and shutdown the mainloop + try: + self._doRead() + except Exception, e: + log.exception(e) + reactor.stop() + + def _doRead(self): + # Read the character + c = self.stdscr.getch() + self.stdscr.refresh() + + def close(self): + """ + Clean up the curses stuff on exit. + """ + curses.nocbreak() + self.stdscr.keypad(0) + curses.echo() + curses.endwin() diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py new file mode 100644 index 000000000..84b542d85 --- /dev/null +++ b/deluge/ui/console/modes/input_popup.py @@ -0,0 +1,244 @@ +# +# input_popup.py +# +# Copyright (C) 2011 Nick Lanham +# +# Complete function from commands/add.py: +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +try: + import curses +except ImportError: + pass + +import logging,os.path + +from popup import Popup + +log = logging.getLogger(__name__) + +def complete(line): + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + #ret.extend(os.listdir(line)) + for f in os.listdir(line): + # Skip hidden + if f.startswith("."): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + f += "/" + ret.append(f) + else: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join( os.path.dirname(line), f)) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + p += "/" + ret.append(p) + + return ret + + +class InputPopup(Popup): + def __init__(self,parent_mode,title,width_req=-1,height_req=-1,close_cb=None): + Popup.__init__(self,parent_mode,title,width_req,height_req,close_cb) + self.input_list = [] + self.input_states = {} + self.current_input = 0 + self.tab_count = 0 + self.opts = None + self.opt_off = 0 + curses.curs_set(2) + + def add_text_input(self, message, name, value="", complete=True): + """ + Add a text input field to the popup. + + :param message: string to display above the input field + :param name: name of the field, for the return callback + :param value: initial value of the field + :param complete: should completion be run when tab is hit and this field is active + """ + self.input_list.append(name) + self.input_states[name] = [message,value,0,complete] + + def _refresh_lines(self): + crow = 1 + for i,ipt in enumerate(self.input_list): + msg,txt,curs,comp = self.input_states[ipt] + if i == self.current_input: + curs_row = crow+1 + curs_col = curs + if self.opts: + self.parent.add_string(crow+2,self.opts[self.opt_off:],self.screen,1,False,True) + self.parent.add_string(crow,msg,self.screen,1,False,True) + self.parent.add_string(crow+1,"{!selected!}%s"%txt.ljust(self.width-2),self.screen,1,False,False) + crow += 3 + + self.screen.move(curs_row,curs_col+1) + + def _get_current_input_value(self): + return self.input_states[self.input_list[self.current_input]][1] + + def _set_current_input_value(self, val): + self.input_states[self.input_list[self.current_input]][1] = val + + def _get_current_cursor(self): + return self.input_states[self.input_list[self.current_input]][2] + + def _move_current_cursor(self, amt): + self.input_states[self.input_list[self.current_input]][2] += amt + + def _set_current_cursor(self, pos): + self.input_states[self.input_list[self.current_input]][2] = pos + + # most of the cursor,input stuff here taken from ui/console/screen.py + def handle_read(self, c): + if c == curses.KEY_UP: + self.current_input = max(0,self.current_input-1) + elif c == curses.KEY_DOWN: + self.current_input = min(len(self.input_list)-1,self.current_input+1) + + elif c == curses.KEY_ENTER or c == 10: + if self._close_cb: + vals = {} + for ipt in self.input_list: + vals[ipt] = self.input_states[ipt][1] + curses.curs_set(0) + self._close_cb(vals) + return True # close the popup + + elif c == 9: + # Keep track of tab hit count to know when it's double-hit + self.tab_count += 1 + if self.tab_count > 1: + second_hit = True + self.tab_count = 0 + else: + second_hit = False + + # We only call the tab completer function if we're at the end of + # the input string on the cursor is on a space + cur_input = self._get_current_input_value() + cur_cursor = self._get_current_cursor() + if cur_cursor == len(cur_input) or cur_input[cur_cursor] == " ": + if self.opts: + prev = self.opt_off + self.opt_off += self.width-3 + # now find previous double space, best guess at a split point + # in future could keep opts unjoined to get this really right + self.opt_off = self.opts.rfind(" ",0,self.opt_off)+2 + if second_hit and self.opt_off == prev: # double tap and we're at the end + self.opt_off = 0 + else: + opts = complete(cur_input) + if len(opts) == 1: # only one option, just complete it + self._set_current_input_value(opts[0]) + self._set_current_cursor(len(opts[0])) + self.tab_count = 0 + elif len(opts) > 1 and second_hit: # display multiple options on second tab hit + self.opts = " ".join(opts) + + elif c == 27: # close on esc, no action + return True + + # Cursor movement + elif c == curses.KEY_LEFT: + if self._get_current_cursor(): + self._move_current_cursor(-1) + elif c == curses.KEY_RIGHT: + if self._get_current_cursor() < len(self._get_current_input_value()): + self._move_current_cursor(1) + elif c == curses.KEY_HOME: + self._set_current_cursor(0) + elif c == curses.KEY_END: + self._set_current_cursor(len(self._get_current_input_value())) + + if c != 9: + self.opts = None + self.opt_off = 0 + self.tab_count = 0 + + # Delete a character in the input string based on cursor position + if c == curses.KEY_BACKSPACE or c == 127: + cur_input = self._get_current_input_value() + cur_cursor = self._get_current_cursor() + if cur_input and cur_cursor > 0: + self._set_current_input_value(cur_input[:cur_cursor - 1] + cur_input[cur_cursor:]) + self._move_current_cursor(-1) + elif c == curses.KEY_DC: + cur_input = self._get_current_input_value() + cur_cursor = self._get_current_cursor() + if cur_input and cur_cursor < len(cur_input): + self._set_current_input_value(cur_input[:cur_cursor] + cur_input[cur_cursor+1:]) + elif c > 31 and c < 256: + cur_input = self._get_current_input_value() + cur_cursor = self._get_current_cursor() + # Emulate getwch + stroke = chr(c) + uchar = "" + while not uchar: + try: + uchar = stroke.decode(self.parent.encoding) + except UnicodeDecodeError: + c = self.parent.stdscr.getch() + stroke += chr(c) + if uchar: + if cur_cursor == len(cur_input): + self._set_current_input_value(cur_input+uchar) + else: + # Insert into string + self._set_current_input_value(cur_input[:cur_cursor] + uchar + cur_input[cur_cursor:]) + # Move the cursor forward + self._move_current_cursor(1) + + self.refresh() + return False + diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py new file mode 100644 index 000000000..93df9f6f8 --- /dev/null +++ b/deluge/ui/console/modes/popup.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# +# popup.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +try: + import curses + import signal +except ImportError: + pass + +import logging +log = logging.getLogger(__name__) + +class Popup: + def __init__(self,parent_mode,title,width_req=-1,height_req=-1,close_cb=None): + """ + Init a new popup. The default constructor will handle sizing and borders and the like. + + NB: The parent mode is responsible for calling refresh on any popups it wants to show. + This should be called as the last thing in the parents refresh method. + + The parent *must* also call _doRead on the popup instead of/in addition to + running its own _doRead code if it wants to have the popup handle user input. + + :param parent_mode: must be a basemode (or subclass) which the popup will be drawn over + :parem title: string, the title of the popup window + + Popups have two methods that must be implemented: + + refresh(self) - draw the popup window to screen. this default mode simply draws a bordered window + with the supplied title to the screen + + add_string(self, row, string) - add string at row. handles triming/ignoring if the string won't fit in the popup + + _doRead(self) - handle user input to the popup. + """ + self.parent = parent_mode + + if (height_req <= 0): + height_req = int(self.parent.rows/2) + if (width_req <= 0): + width_req = int(self.parent.cols/2) + by = (self.parent.rows/2)-(height_req/2) + bx = (self.parent.cols/2)-(width_req/2) + self.screen = curses.newwin(height_req,width_req,by,bx) + + self.title = title + self._close_cb = close_cb + self.height,self.width = self.screen.getmaxyx() + self._divider = None + self._lineoff = 0 + self._lines = [] + + def _refresh_lines(self): + crow = 1 + for row,line in enumerate(self._lines): + if (crow >= self.height-1): + break + if (row < self._lineoff): + continue + self.parent.add_string(crow,line,self.screen,1,False,True) + crow+=1 + + def handle_resize(self): + log.debug("Resizing popup window (actually, just creating a new one)") + self.screen = curses.newwin((self.parent.rows/2),(self.parent.cols/2),(self.parent.rows/4),(self.parent.cols/4)) + self.height,self.width = self.screen.getmaxyx() + + + def refresh(self): + self.screen.clear() + self.screen.border(0,0,0,0) + toff = max(1,int((self.parent.cols/4)-(len(self.title)/2))) + self.parent.add_string(0,"{!white,black,bold!}%s"%self.title,self.screen,toff,False,True) + + self._refresh_lines() + + self.screen.redrawwin() + self.screen.noutrefresh() + + def clear(self): + self._lines = [] + + def handle_read(self, c): + if c == curses.KEY_UP: + self._lineoff = max(0,self._lineoff -1) + elif c == curses.KEY_DOWN: + if len(self._lines)-self._lineoff > (self.height-2): + self._lineoff += 1 + + elif c == curses.KEY_ENTER or c == 10 or c == 27: # close on enter/esc + if self._close_cb: + self._close_cb() + return True # close the popup + + if c > 31 and c < 256 and chr(c) == 'q': + if self._close_cb: + self._close_cb() + return True # close the popup + + self.refresh() + + return False + + def set_title(self, title): + self.title = title + + def add_line(self, string): + self._lines.append(string) + + def add_divider(self): + if not self._divider: + self._divider = "-"*(self.width-2) + self._lines.append(self._divider) + + +class SelectablePopup(Popup): + """ + A popup which will let the user select from some of the lines that + are added. + """ + def __init__(self,parent_mode,title,selection_callback,*args): + Popup.__init__(self,parent_mode,title) + self._selection_callback = selection_callback + self._selection_args = args + self._selectable_lines = [] + self._select_data = [] + self._line_foregrounds = [] + self._selected = -1 + + def add_line(self, string, selectable=True, data=None, foreground=None): + Popup.add_line(self,string) + self._line_foregrounds.append(foreground) + if selectable: + self._selectable_lines.append(len(self._lines)-1) + self._select_data.append(data) + if self._selected < 0: + self._selected = (len(self._lines)-1) + + def _refresh_lines(self): + crow = 1 + for row,line in enumerate(self._lines): + if (crow >= self.height-1): + break + if (row < self._lineoff): + continue + fg = self._line_foregrounds[row] + if row == self._selected: + if fg == None: fg = "black" + colorstr = "{!%s,white,bold!}"%fg + else: + if fg == None: fg = "white" + colorstr = "{!%s,black!}"%fg + self.parent.add_string(crow,"- %s%s"%(colorstr,line),self.screen,1,False,True) + crow+=1 + + def add_divider(self,color="white"): + if not self._divider: + self._divider = "-"*(self.width-6)+" -" + self._lines.append(self._divider) + self._line_foregrounds.append(color) + + def handle_read(self, c): + if c == curses.KEY_UP: + #self._lineoff = max(0,self._lineoff -1) + if (self._selected != self._selectable_lines[0] and + len(self._selectable_lines) > 1): + idx = self._selectable_lines.index(self._selected) + self._selected = self._selectable_lines[idx-1] + elif c == curses.KEY_DOWN: + #if len(self._lines)-self._lineoff > (self.height-2): + # self._lineoff += 1 + idx = self._selectable_lines.index(self._selected) + if (idx < len(self._selectable_lines)-1): + self._selected = self._selectable_lines[idx+1] + elif c == 27: # close on esc, no action + return True + elif c == curses.KEY_ENTER or c == 10: + idx = self._selectable_lines.index(self._selected) + self._selection_callback(idx,self._select_data[idx],*self._selection_args) + return True + if c > 31 and c < 256 and chr(c) == 'q': + return True # close the popup + + self.refresh() + + return False + + +class MessagePopup(Popup): + """ + Popup that just displays a message + """ + import re + _strip_re = re.compile("\{!.*?!\}") + _min_height = 3 + + def __init__(self, parent_mode, title, message): + self.message = message + self.width= int(parent_mode.cols/2) + lns = self._split_message(self.message) + height = max(len(lns),self._min_height) + Popup.__init__(self,parent_mode,title,height_req=(height+2)) + lft = height - len(lns) + if lft: + for i in range(0,int(lft/2)): + lns.insert(0,"") + self._lines = lns + + def _split_message(self,message): + ret = [] + wl = (self.width-2) + for i in range(0,len(self.message),wl): + l = self.message[i:i+wl] + lp = (wl-len(self._strip_re.sub('',l)))/2 + ret.append("%s%s"%(lp*" ",l)) + return ret + + def handle_resize(self): + Popup.handle_resize(self) + self.clear() + self._lines = self._split_message(self.message) From ba3a093746a51fd46b9b0bcaf72847549e0c94b3 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 28 Jan 2011 17:04:28 +0100 Subject: [PATCH 02/55] remove special case white/black pair. doesn't seem needed and breaks white,black,attrs --- deluge/ui/console/colors.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/deluge/ui/console/colors.py b/deluge/ui/console/colors.py index 7d7944b3c..9988ab882 100644 --- a/deluge/ui/console/colors.py +++ b/deluge/ui/console/colors.py @@ -50,9 +50,7 @@ colors = [ ] # {(fg, bg): pair_number, ...} -color_pairs = { - ("white", "black"): 0 # Special case, can't be changed -} +color_pairs = {} # Some default color schemes schemes = { @@ -93,8 +91,6 @@ def init_colors(): counter = 1 for fg in colors: for bg in colors: - if fg == "COLOR_WHITE" and bg == "COLOR_BLACK": - continue color_pairs[(fg[6:].lower(), bg[6:].lower())] = counter curses.init_pair(counter, getattr(curses, fg), getattr(curses, bg)) counter += 1 From 6f0b1fd7f25613b2b31721d62c431d21826136c3 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 28 Jan 2011 17:33:51 +0100 Subject: [PATCH 03/55] support hotkeys in selectable popup --- deluge/ui/console/modes/popup.py | 36 +++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py index 93df9f6f8..8b0d911eb 100644 --- a/deluge/ui/console/modes/popup.py +++ b/deluge/ui/console/modes/popup.py @@ -158,9 +158,18 @@ class SelectablePopup(Popup): self._selectable_lines = [] self._select_data = [] self._line_foregrounds = [] + self._udxs = {} + self._hotkeys = {} self._selected = -1 - def add_line(self, string, selectable=True, data=None, foreground=None): + def add_line(self, string, selectable=True, use_underline=True, data=None, foreground=None): + if use_underline: + udx = string.find('_') + if udx >= 0: + string = string[:udx]+string[udx+1:] + self._udxs[len(self._lines)+1] = udx + c = string[udx].lower() + self._hotkeys[c] = len(self._lines) Popup.add_line(self,string) self._line_foregrounds.append(foreground) if selectable: @@ -177,13 +186,24 @@ class SelectablePopup(Popup): if (row < self._lineoff): continue fg = self._line_foregrounds[row] + udx = self._udxs.get(crow) if row == self._selected: if fg == None: fg = "black" colorstr = "{!%s,white,bold!}"%fg + if udx >= 0: + ustr = "{!%s,white,bold,underline!}"%fg else: if fg == None: fg = "white" colorstr = "{!%s,black!}"%fg - self.parent.add_string(crow,"- %s%s"%(colorstr,line),self.screen,1,False,True) + if udx >= 0: + ustr = "{!%s,black,underline!}"%fg + if udx == 0: + self.parent.add_string(crow,"- %s%c%s%s"%(ustr,line[0],colorstr,line[1:]),self.screen,1,False,True) + elif udx > 0: + # well, this is a litte gross + self.parent.add_string(crow,"- %s%s%s%c%s%s"%(colorstr,line[:udx],ustr,line[udx],colorstr,line[udx+1:]),self.screen,1,False,True) + else: + self.parent.add_string(crow,"- %s%s"%(colorstr,line),self.screen,1,False,True) crow+=1 def add_divider(self,color="white"): @@ -211,9 +231,15 @@ class SelectablePopup(Popup): idx = self._selectable_lines.index(self._selected) self._selection_callback(idx,self._select_data[idx],*self._selection_args) return True - if c > 31 and c < 256 and chr(c) == 'q': - return True # close the popup - + if c > 31 and c < 256: + if chr(c) == 'q': + return True # close the popup + uc = chr(c).lower() + if uc in self._hotkeys: + # exec hotkey action + idx = self._selectable_lines.index(self._hotkeys[uc]) + self._selection_callback(idx,self._select_data[idx],*self._selection_args) + return True self.refresh() return False From 182ec0cd97708b62e8cfd133b7d2b3f42a442303 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 28 Jan 2011 17:34:03 +0100 Subject: [PATCH 04/55] specify hotkeys for filter/action popups --- deluge/ui/console/modes/alltorrents.py | 39 +++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index dbd50676e..dbb6da645 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -357,13 +357,13 @@ class AllTorrents(BaseMode, component.Component): #cid = self._current_torrent_id() if len(self.marked): self.popup = SelectablePopup(self,"Torrent Actions",self._torrent_action) - self.popup.add_line("Pause",data=ACTION.PAUSE) - self.popup.add_line("Resume",data=ACTION.RESUME) + self.popup.add_line("_Pause",data=ACTION.PAUSE) + self.popup.add_line("_Resume",data=ACTION.RESUME) self.popup.add_divider() - self.popup.add_line("Update Tracker",data=ACTION.REANNOUNCE) + self.popup.add_line("_Update Tracker",data=ACTION.REANNOUNCE) self.popup.add_divider() - self.popup.add_line("Remove Torrent",data=ACTION.REMOVE) - self.popup.add_line("Force Recheck",data=ACTION.RECHECK) + self.popup.add_line("Remo_ve Torrent",data=ACTION.REMOVE) + self.popup.add_line("_Force Recheck",data=ACTION.RECHECK) def _torrent_filter(self, idx, data): if data==FILTER.ALL: @@ -394,14 +394,14 @@ class AllTorrents(BaseMode, component.Component): def _show_torrent_filter_popup(self): self.popup = SelectablePopup(self,"Filter Torrents",self._torrent_filter) - self.popup.add_line("All",data=FILTER.ALL) - self.popup.add_line("Active",data=FILTER.ACTIVE) - self.popup.add_line("Downloading",data=FILTER.DOWNLOADING,foreground="green") - self.popup.add_line("Seeding",data=FILTER.SEEDING,foreground="blue") - self.popup.add_line("Paused",data=FILTER.PAUSED) - self.popup.add_line("Error",data=FILTER.ERROR,foreground="red") - self.popup.add_line("Checking",data=FILTER.CHECKING,foreground="cyan") - self.popup.add_line("Queued",data=FILTER.QUEUED,foreground="yellow") + self.popup.add_line("_All",data=FILTER.ALL) + self.popup.add_line("Ac_tive",data=FILTER.ACTIVE) + self.popup.add_line("_Downloading",data=FILTER.DOWNLOADING,foreground="green") + self.popup.add_line("_Seeding",data=FILTER.SEEDING,foreground="cyan") + self.popup.add_line("_Paused",data=FILTER.PAUSED) + self.popup.add_line("_Error",data=FILTER.ERROR,foreground="red") + self.popup.add_line("_Checking",data=FILTER.CHECKING,foreground="blue") + self.popup.add_line("Q_ueued",data=FILTER.QUEUED,foreground="yellow") def _do_add(self, result): log.debug("Doing adding %s (dl to %s)",result["file"],result["path"]) @@ -444,7 +444,8 @@ class AllTorrents(BaseMode, component.Component): else: self.add_string(0,"%s {!filterstatus!}Current filter: %s"%(self.topbar,self._curr_filter)) self.add_string(1,self.column_string) - self.add_string(self.rows - 1, self.bottombar) + hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.bottombar) - 10)) + self.add_string(self.rows - 1, "%s%s"%(self.bottombar,hstr)) # add all the torrents if self.curstate == {}: @@ -490,13 +491,13 @@ class AllTorrents(BaseMode, component.Component): if ts["state"] == "Downloading": fg = "green" elif ts["state"] == "Seeding": - fg = "blue" + fg = "cyan" elif ts["state"] == "Error": fg = "red" elif ts["state"] == "Queued": fg = "yellow" elif ts["state"] == "Checking": - fg = "cyan" + fg = "blue" if attr: colorstr = "{!%s,%s,%s!}"%(fg,bg,attr) @@ -574,7 +575,11 @@ class AllTorrents(BaseMode, component.Component): else: if c > 31 and c < 256: - if chr(c) == 'i': + if chr(c) == 'j': + self._scroll_up(1) + elif chr(c) == 'k': + self._scroll_down(1) + elif chr(c) == 'i': cid = self._current_torrent_id() if cid: self.popup = Popup(self,"Info",close_cb=lambda:self.updater.set_torrent_to_update(None,None)) From 44676f282ae8a8acdb14eb61588aaca923c5416d Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 28 Jan 2011 17:54:36 +0100 Subject: [PATCH 05/55] use some caching to speed up row drawing. still some flicker unfortunatly. seems to be related to the length of the row line, not sure if there's much i can do there --- deluge/ui/console/modes/alltorrents.py | 57 +++++++++++++------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index dbb6da645..244fbe37b 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -145,7 +145,7 @@ class StateUpdater(component.Component): class AllTorrents(BaseMode, component.Component): def __init__(self, stdscr, coreconfig, encoding=None): - self.curstate = None + self.formatted_rows = None self.cursel = 1 self.curoff = 1 self.column_string = "" @@ -231,8 +231,23 @@ class AllTorrents(BaseMode, component.Component): return "".join([self._format_column(row[i],self.column_widths[i]) for i in range(0,len(row))]) def set_state(self, state, refresh): - self.curstate = state + self.curstate = state # cache in case we change sort order + newrows = [] + self._sorted_ids = self._sort_torrents(self.curstate) + for torrent_id in self._sorted_ids: + ts = self.curstate[torrent_id] + newrows.append((self._format_row([self._format_queue(ts["queue"]), + ts["name"], + "%s"%deluge.common.fsize(ts["total_wanted"]), + ts["state"], + self._format_progress(ts["progress"]), + self._format_seeds_peers(ts["num_seeds"],ts["total_seeds"]), + self._format_seeds_peers(ts["num_peers"],ts["total_peers"]), + self._format_speed(ts["download_payload_rate"]), + self._format_speed(ts["upload_payload_rate"]) + ]),ts["state"])) self.numtorrents = len(state) + self.formatted_rows = newrows if refresh: self.refresh() @@ -243,6 +258,7 @@ class AllTorrents(BaseMode, component.Component): def _scroll_down(self, by): self.cursel = min(self.cursel + by,self.numtorrents) + log.error("cursel: %d",self.cursel) if ((self.curoff + self.rows - 5) < self.cursel): self.curoff = self.cursel - self.rows + 5 @@ -448,29 +464,14 @@ class AllTorrents(BaseMode, component.Component): self.add_string(self.rows - 1, "%s%s"%(self.bottombar,hstr)) # add all the torrents - if self.curstate == {}: + if self.formatted_rows == []: msg = "No torrents match filter".center(self.cols) self.add_string(3, "{!info!}%s"%msg) - elif self.curstate != None: - tidx = 1 + elif self.formatted_rows: + tidx = self.curoff currow = 2 - self._sorted_ids = self._sort_torrents(self.curstate) - for torrent_id in self._sorted_ids: - if (tidx < self.curoff): - tidx += 1 - continue - ts = self.curstate[torrent_id] - s = self._format_row([self._format_queue(ts["queue"]), - ts["name"], - "%s"%deluge.common.fsize(ts["total_wanted"]), - ts["state"], - self._format_progress(ts["progress"]), - self._format_seeds_peers(ts["num_seeds"],ts["total_seeds"]), - self._format_seeds_peers(ts["num_peers"],ts["total_peers"]), - self._format_speed(ts["download_payload_rate"]), - self._format_speed(ts["upload_payload_rate"]) - ]) + for row in self.formatted_rows[tidx-1:]: # default style fg = "white" bg = "black" @@ -488,22 +489,22 @@ class AllTorrents(BaseMode, component.Component): else: fg = "black" - if ts["state"] == "Downloading": + if row[1] == "Downloading": fg = "green" - elif ts["state"] == "Seeding": + elif row[1] == "Seeding": fg = "cyan" - elif ts["state"] == "Error": + elif row[1] == "Error": fg = "red" - elif ts["state"] == "Queued": + elif row[1] == "Queued": fg = "yellow" - elif ts["state"] == "Checking": + elif row[1] == "Checking": fg = "blue" if attr: colorstr = "{!%s,%s,%s!}"%(fg,bg,attr) else: colorstr = "{!%s,%s!}"%(fg,bg) - self.add_string(currow,"%s%s"%(colorstr,s)) + self.add_string(currow,"%s%s"%(colorstr,row[0])) tidx += 1 currow += 1 if (currow > (self.rows - 2)): @@ -550,7 +551,7 @@ class AllTorrents(BaseMode, component.Component): reactor.stop() return - if self.curstate==None or self.popup: + if self.formatted_rows==None or self.popup: return #log.error("pressed key: %d\n",c) From 68c04acf5014f10756c07eb7f18413f71c10eb87 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 28 Jan 2011 22:42:04 +0100 Subject: [PATCH 06/55] refactor + support selectinput --- deluge/ui/console/modes/input_popup.py | 298 ++++++++++++++----------- 1 file changed, 172 insertions(+), 126 deletions(-) diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py index 84b542d85..da6139b13 100644 --- a/deluge/ui/console/modes/input_popup.py +++ b/deluge/ui/console/modes/input_popup.py @@ -48,114 +48,79 @@ from popup import Popup log = logging.getLogger(__name__) -def complete(line): - line = os.path.abspath(os.path.expanduser(line)) - ret = [] - if os.path.exists(line): - # This is a correct path, check to see if it's a directory - if os.path.isdir(line): - # Directory, so we need to show contents of directory - #ret.extend(os.listdir(line)) - for f in os.listdir(line): - # Skip hidden - if f.startswith("."): - continue - f = os.path.join(line, f) - if os.path.isdir(f): - f += "/" - ret.append(f) - else: - # This is a file, but we could be looking for another file that - # shares a common prefix. - for f in os.listdir(os.path.dirname(line)): - if f.startswith(os.path.split(line)[1]): - ret.append(os.path.join( os.path.dirname(line), f)) - else: - # This path does not exist, so lets do a listdir on it's parent - # and find any matches. - ret = [] - if os.path.isdir(os.path.dirname(line)): - for f in os.listdir(os.path.dirname(line)): - if f.startswith(os.path.split(line)[1]): - p = os.path.join(os.path.dirname(line), f) +class InputField: + # render the input. return number of rows taken up + def render(self,screen,row,width,selected): + return 0 + def handle_read(self, c): + if c in [curses.KEY_ENTER, 10, 127, 113]: + return True + return False + def get_value(self): + return None - if os.path.isdir(p): - p += "/" - ret.append(p) +class SelectInput(InputField): + def __init__(self, parent, message, name, opts, selidx): + self.parent = parent + self.message = message + self.name = name + self.opts = opts + self.selidx = selidx - return ret + def render(self, screen, row, width, selected): + self.parent.add_string(row,self.message,screen,1,False,True) + off = 2 + for i,opt in enumerate(self.opts): + if selected and i == self.selidx: + self.parent.add_string(row+1,"{!black,white,bold!}[%s]"%opt,screen,off,False,True) + elif i == self.selidx: + self.parent.add_string(row+1,"[{!white,black,underline!}%s{!white,black!}]"%opt,screen,off,False,True) + else: + self.parent.add_string(row+1,"[%s]"%opt,screen,off,False,True) + off += len(opt)+3 + return 2 + def handle_read(self, c): + if c == curses.KEY_LEFT: + self.selidx = max(0,self.selidx-1) + if c == curses.KEY_RIGHT: + self.selidx = min(len(self.opts)-1,self.selidx+1) + + def get_value(self): + return self.opts[self.selidx] + +class TextInput(InputField): + def __init__(self, parent, move_func, width, message, name, value, docmp): + self.parent = parent + self.move_func = move_func + self.width = width + + self.message = message + self.name = name + self.value = value + self.docmp = docmp -class InputPopup(Popup): - def __init__(self,parent_mode,title,width_req=-1,height_req=-1,close_cb=None): - Popup.__init__(self,parent_mode,title,width_req,height_req,close_cb) - self.input_list = [] - self.input_states = {} - self.current_input = 0 self.tab_count = 0 + self.cursor = 0 self.opts = None self.opt_off = 0 - curses.curs_set(2) - def add_text_input(self, message, name, value="", complete=True): - """ - Add a text input field to the popup. - - :param message: string to display above the input field - :param name: name of the field, for the return callback - :param value: initial value of the field - :param complete: should completion be run when tab is hit and this field is active - """ - self.input_list.append(name) - self.input_states[name] = [message,value,0,complete] + def render(self,screen,row,width,selected): + if selected: + if self.opts: + self.parent.add_string(row+2,self.opts[self.opt_off:],screen,1,False,True) + self.move_func(row+1,self.cursor+1) + self.parent.add_string(row,self.message,screen,1,False,True) + self.parent.add_string(row+1,"{!black,white,bold!}%s"%self.value.ljust(width-2),screen,1,False,False) - def _refresh_lines(self): - crow = 1 - for i,ipt in enumerate(self.input_list): - msg,txt,curs,comp = self.input_states[ipt] - if i == self.current_input: - curs_row = crow+1 - curs_col = curs - if self.opts: - self.parent.add_string(crow+2,self.opts[self.opt_off:],self.screen,1,False,True) - self.parent.add_string(crow,msg,self.screen,1,False,True) - self.parent.add_string(crow+1,"{!selected!}%s"%txt.ljust(self.width-2),self.screen,1,False,False) - crow += 3 + return 3 - self.screen.move(curs_row,curs_col+1) - - def _get_current_input_value(self): - return self.input_states[self.input_list[self.current_input]][1] - - def _set_current_input_value(self, val): - self.input_states[self.input_list[self.current_input]][1] = val - - def _get_current_cursor(self): - return self.input_states[self.input_list[self.current_input]][2] - - def _move_current_cursor(self, amt): - self.input_states[self.input_list[self.current_input]][2] += amt - - def _set_current_cursor(self, pos): - self.input_states[self.input_list[self.current_input]][2] = pos + def get_value(self): + return self.value # most of the cursor,input stuff here taken from ui/console/screen.py - def handle_read(self, c): - if c == curses.KEY_UP: - self.current_input = max(0,self.current_input-1) - elif c == curses.KEY_DOWN: - self.current_input = min(len(self.input_list)-1,self.current_input+1) - - elif c == curses.KEY_ENTER or c == 10: - if self._close_cb: - vals = {} - for ipt in self.input_list: - vals[ipt] = self.input_states[ipt][1] - curses.curs_set(0) - self._close_cb(vals) - return True # close the popup - - elif c == 9: + def handle_read(self,c): + if c == 9 and self.docmp: # Keep track of tab hit count to know when it's double-hit self.tab_count += 1 if self.tab_count > 1: @@ -166,9 +131,7 @@ class InputPopup(Popup): # We only call the tab completer function if we're at the end of # the input string on the cursor is on a space - cur_input = self._get_current_input_value() - cur_cursor = self._get_current_cursor() - if cur_cursor == len(cur_input) or cur_input[cur_cursor] == " ": + if self.cursor == len(self.value) or self.value[self.cursor] == " ": if self.opts: prev = self.opt_off self.opt_off += self.width-3 @@ -178,28 +141,23 @@ class InputPopup(Popup): if second_hit and self.opt_off == prev: # double tap and we're at the end self.opt_off = 0 else: - opts = complete(cur_input) + opts = self.complete(self.value) if len(opts) == 1: # only one option, just complete it - self._set_current_input_value(opts[0]) - self._set_current_cursor(len(opts[0])) + self.value = opts[0] + self.cursor = len(opts[0]) self.tab_count = 0 elif len(opts) > 1 and second_hit: # display multiple options on second tab hit self.opts = " ".join(opts) - elif c == 27: # close on esc, no action - return True - # Cursor movement elif c == curses.KEY_LEFT: - if self._get_current_cursor(): - self._move_current_cursor(-1) + self.cursor = max(0,self.cursor-1) elif c == curses.KEY_RIGHT: - if self._get_current_cursor() < len(self._get_current_input_value()): - self._move_current_cursor(1) + self.cursor = min(len(self.value),self.cursor+1) elif c == curses.KEY_HOME: - self._set_current_cursor(0) + self.cursor = 0 elif c == curses.KEY_END: - self._set_current_cursor(len(self._get_current_input_value())) + self.cursor = len(self.value) if c != 9: self.opts = None @@ -208,19 +166,13 @@ class InputPopup(Popup): # Delete a character in the input string based on cursor position if c == curses.KEY_BACKSPACE or c == 127: - cur_input = self._get_current_input_value() - cur_cursor = self._get_current_cursor() - if cur_input and cur_cursor > 0: - self._set_current_input_value(cur_input[:cur_cursor - 1] + cur_input[cur_cursor:]) - self._move_current_cursor(-1) + if self.value and self.cursor > 0: + self.value = self.value[:self.cursor - 1] + self.value[self.cursor:] + self.cursor-=1 elif c == curses.KEY_DC: - cur_input = self._get_current_input_value() - cur_cursor = self._get_current_cursor() - if cur_input and cur_cursor < len(cur_input): - self._set_current_input_value(cur_input[:cur_cursor] + cur_input[cur_cursor+1:]) + if self.value and self.cursor < len(self.value): + self.value = self.value[:self.cursor] + self.value[self.cursor+1:] elif c > 31 and c < 256: - cur_input = self._get_current_input_value() - cur_cursor = self._get_current_cursor() # Emulate getwch stroke = chr(c) uchar = "" @@ -231,13 +183,107 @@ class InputPopup(Popup): c = self.parent.stdscr.getch() stroke += chr(c) if uchar: - if cur_cursor == len(cur_input): - self._set_current_input_value(cur_input+uchar) + if self.cursor == len(self.value): + self.value += uchar else: # Insert into string - self._set_current_input_value(cur_input[:cur_cursor] + uchar + cur_input[cur_cursor:]) + self.value = self.value[:self.cursor] + uchar + self.value[self.cursor:] # Move the cursor forward - self._move_current_cursor(1) + self.cursor+=1 + + def complete(self,line): + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + #ret.extend(os.listdir(line)) + for f in os.listdir(line): + # Skip hidden + if f.startswith("."): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + f += "/" + ret.append(f) + else: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join( os.path.dirname(line), f)) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + p += "/" + ret.append(p) + + return ret + + +class InputPopup(Popup): + def __init__(self,parent_mode,title,width_req=-1,height_req=-1,close_cb=None): + Popup.__init__(self,parent_mode,title,width_req,height_req,close_cb) + self.inputs = [] + self.current_input = 0 + + def move(self,r,c): + self._cursor_row = r + self._cursor_col = c + + def add_text_input(self, message, name, value="", complete=True): + """ + Add a text input field to the popup. + + :param message: string to display above the input field + :param name: name of the field, for the return callback + :param value: initial value of the field + :param complete: should completion be run when tab is hit and this field is active + """ + self.inputs.append(TextInput(self.parent, self.move, self.width, message, + name, value, complete)) + + def add_select_input(self, message, name, opts, default_index=0): + self.inputs.append(SelectInput(self.parent, message, name, opts, default_index)) + + def _refresh_lines(self): + self._cursor_row = -1 + self._cursor_col = -1 + curses.curs_set(0) + crow = 1 + for i,ipt in enumerate(self.inputs): + crow += ipt.render(self.screen,crow,self.width,i==self.current_input) + + # need to do this last as adding things moves the cursor + if self._cursor_row >= 0: + curses.curs_set(2) + self.screen.move(self._cursor_row,self._cursor_col) + + def handle_read(self, c): + if c == curses.KEY_UP: + self.current_input = max(0,self.current_input-1) + elif c == curses.KEY_DOWN: + self.current_input = min(len(self.inputs)-1,self.current_input+1) + elif c == curses.KEY_ENTER or c == 10: + if self._close_cb: + vals = {} + for ipt in self.inputs: + vals[ipt.name] = ipt.get_value() + curses.curs_set(0) + self._close_cb(vals) + return True # close the popup + elif c == 27: # close on esc, no action + return True + elif self.inputs: + self.inputs[self.current_input].handle_read(c) self.refresh() return False From ff3c3f71483b1e862337827e8314c0d2e8bbcbd2 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 29 Jan 2011 12:29:18 +0100 Subject: [PATCH 07/55] add torrent can add paused. remove torrent works --- deluge/ui/console/modes/add_util.py | 1 + deluge/ui/console/modes/alltorrents.py | 36 +++++++++++++++++++++++--- deluge/ui/console/modes/popup.py | 6 ++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/deluge/ui/console/modes/add_util.py b/deluge/ui/console/modes/add_util.py index d9ced794b..28372631d 100644 --- a/deluge/ui/console/modes/add_util.py +++ b/deluge/ui/console/modes/add_util.py @@ -49,6 +49,7 @@ def add_torrent(t_file, options, success_cb, fail_cb): t_options = {} if options["path"]: t_options["download_location"] = os.path.expanduser(options["path"]) + t_options["add_paused"] = options["add_paused"] # Keep a list of deferreds to make a DeferredList if not os.path.exists(t_file): diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 244fbe37b..55e12da97 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -108,6 +108,9 @@ class ACTION: RECHECK=4 REMOVE=5 + REMOVE_DATA=6 + REMOVE_NODATA=7 + class FILTER: ALL=0 ACTIVE=1 @@ -258,7 +261,6 @@ class AllTorrents(BaseMode, component.Component): def _scroll_down(self, by): self.cursel = min(self.cursel + by,self.numtorrents) - log.error("cursel: %d",self.cursel) if ((self.curoff + self.rows - 5) < self.cursel): self.curoff = self.cursel - self.rows + 5 @@ -349,6 +351,7 @@ class AllTorrents(BaseMode, component.Component): self.refresh() def _torrent_action(self, idx, data): + log.error("Action %d",data) ids = self._selected_torrent_ids() if ids: if data==ACTION.PAUSE: @@ -358,7 +361,23 @@ class AllTorrents(BaseMode, component.Component): log.debug("Resuming torrents: %s", ids) client.core.resume_torrent(ids).addErrback(self._action_error) elif data==ACTION.REMOVE: - log.error("Can't remove just yet") + def do_remove(tid,data): + ids = self._selected_torrent_ids() + if data: + wd = data==ACTION.REMOVE_DATA + for tid in ids: + log.debug("Removing torrent: %s,%d",tid,wd) + client.core.remove_torrent(tid,wd).addErrback(self._action_error) + if len(ids) == 1: + self.marked = [] + self.last_mark = -1 + return True + self.popup = SelectablePopup(self,"Confirm Remove",do_remove) + self.popup.add_line("Are you sure you want to remove the marked torrents?",selectable=False) + self.popup.add_line("Remove with _data",data=ACTION.REMOVE_DATA) + self.popup.add_line("Remove _torrent",data=ACTION.REMOVE_NODATA) + self.popup.add_line("_Cancel",data=0) + return False elif data==ACTION.RECHECK: log.debug("Rechecking torrents: %s", ids) client.core.force_recheck(ids).addErrback(self._action_error) @@ -368,6 +387,7 @@ class AllTorrents(BaseMode, component.Component): if len(ids) == 1: self.marked = [] self.last_mark = -1 + return True def _show_torrent_actions_popup(self): #cid = self._current_torrent_id() @@ -407,6 +427,7 @@ class AllTorrents(BaseMode, component.Component): self.updater.status_dict = {"state":"Queued"} self._curr_filter = "Queued" self._go_top = True + return True def _show_torrent_filter_popup(self): self.popup = SelectablePopup(self,"Filter Torrents",self._torrent_filter) @@ -420,7 +441,8 @@ class AllTorrents(BaseMode, component.Component): self.popup.add_line("Q_ueued",data=FILTER.QUEUED,foreground="yellow") def _do_add(self, result): - log.debug("Doing adding %s (dl to %s)",result["file"],result["path"]) + result["add_paused"] = (result["add_paused"] == "Yes") + log.debug("Adding Torrent: %s (dl path: %s) (paused: %d)",result["file"],result["path"],result["add_paused"]) def suc_cb(msg): self.report_message("Torrent Added",msg) def fail_cb(msg): @@ -429,13 +451,21 @@ class AllTorrents(BaseMode, component.Component): def _show_torrent_add_popup(self): dl = "" + ap = 1 try: dl = self.coreconfig["download_location"] except KeyError: pass + try: + if self.coreconfig["add_paused"]: + ap = 0 + except KeyError: + pass + self.popup = InputPopup(self,"Add Torrent (Esc to cancel)",close_cb=self._do_add) self.popup.add_text_input("Enter path to torrent file:","file") self.popup.add_text_input("Enter save path:","path",dl) + self.popup.add_select_input("Add Paused:","add_paused",["Yes","No"],ap) def report_message(self,title,message): self.messages.append((title,message)) diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py index 8b0d911eb..bd5bf7858 100644 --- a/deluge/ui/console/modes/popup.py +++ b/deluge/ui/console/modes/popup.py @@ -229,8 +229,7 @@ class SelectablePopup(Popup): return True elif c == curses.KEY_ENTER or c == 10: idx = self._selectable_lines.index(self._selected) - self._selection_callback(idx,self._select_data[idx],*self._selection_args) - return True + return self._selection_callback(idx,self._select_data[idx],*self._selection_args) if c > 31 and c < 256: if chr(c) == 'q': return True # close the popup @@ -238,8 +237,7 @@ class SelectablePopup(Popup): if uc in self._hotkeys: # exec hotkey action idx = self._selectable_lines.index(self._hotkeys[uc]) - self._selection_callback(idx,self._select_data[idx],*self._selection_args) - return True + return self._selection_callback(idx,self._select_data[idx],*self._selection_args) self.refresh() return False From 007dd67ea166125f7f02517999cae118ea24b14b Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 29 Jan 2011 14:04:32 +0100 Subject: [PATCH 08/55] only redraw effected lines on scroll. seems to get rid of the flickering problem :) --- deluge/ui/console/modes/alltorrents.py | 45 ++++++++++++++++++++------ 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 55e12da97..8e64e436b 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -150,7 +150,7 @@ class AllTorrents(BaseMode, component.Component): def __init__(self, stdscr, coreconfig, encoding=None): self.formatted_rows = None self.cursel = 1 - self.curoff = 1 + self.curoff = 1 # TODO: this should really be 0 indexed self.column_string = "" self.popup = None self.messages = deque() @@ -255,14 +255,18 @@ class AllTorrents(BaseMode, component.Component): self.refresh() def _scroll_up(self, by): + prevoff = self.curoff self.cursel = max(self.cursel - by,1) if ((self.cursel - 1) < self.curoff): self.curoff = max(self.cursel - 1,1) + return prevoff != self.curoff def _scroll_down(self, by): + prevoff = self.curoff self.cursel = min(self.cursel + by,self.numtorrents) if ((self.curoff + self.rows - 5) < self.cursel): self.curoff = self.cursel - self.rows + 5 + return prevoff != self.curoff def _current_torrent_id(self): if self._sorted_ids: @@ -470,7 +474,7 @@ class AllTorrents(BaseMode, component.Component): def report_message(self,title,message): self.messages.append((title,message)) - def refresh(self): + def refresh(self,lines=None): # Something has requested we scroll to the top of the list if self._go_top: self.cursel = 1 @@ -482,7 +486,8 @@ class AllTorrents(BaseMode, component.Component): title,msg = self.messages.popleft() self.popup = MessagePopup(self,title,msg) - self.stdscr.clear() + if not lines: + self.stdscr.clear() # Update the status bars if self._curr_filter == None: @@ -501,11 +506,21 @@ class AllTorrents(BaseMode, component.Component): tidx = self.curoff currow = 2 - for row in self.formatted_rows[tidx-1:]: + if lines: + todraw = [] + for l in lines: + todraw.append(self.formatted_rows[l]) + lines.reverse() + else: + todraw = self.formatted_rows[tidx-1:] + + for row in todraw: # default style fg = "white" bg = "black" attr = None + if lines: + tidx = lines.pop()+1 if tidx in self.marked: bg = "blue" @@ -534,6 +549,10 @@ class AllTorrents(BaseMode, component.Component): colorstr = "{!%s,%s,%s!}"%(fg,bg,attr) else: colorstr = "{!%s,%s!}"%(fg,bg) + + if lines: + currow = tidx-self.curoff+2 + self.add_string(currow,"%s%s"%(colorstr,row[0])) tidx += 1 currow += 1 @@ -542,7 +561,7 @@ class AllTorrents(BaseMode, component.Component): else: self.add_string(1, "Waiting for torrents from core...") - self.stdscr.redrawwin() + #self.stdscr.redrawwin() self.stdscr.noutrefresh() if self.popup: @@ -562,6 +581,8 @@ class AllTorrents(BaseMode, component.Component): def _doRead(self): # Read the character + effected_lines = None + c = self.stdscr.getch() if self.popup: @@ -590,11 +611,13 @@ class AllTorrents(BaseMode, component.Component): # Navigate the torrent list if c == curses.KEY_UP: - self._scroll_up(1) + if not self._scroll_up(1): + effected_lines = [self.cursel-1,self.cursel] elif c == curses.KEY_PPAGE: self._scroll_up(int(self.rows/2)) elif c == curses.KEY_DOWN: - self._scroll_down(1) + if not self._scroll_down(1): + effected_lines = [self.cursel-2,self.cursel-1] elif c == curses.KEY_NPAGE: self._scroll_down(int(self.rows/2)) @@ -607,9 +630,11 @@ class AllTorrents(BaseMode, component.Component): else: if c > 31 and c < 256: if chr(c) == 'j': - self._scroll_up(1) + if not self._scroll_up(1): + effected_lines = [self.cursel-1,self.cursel] elif chr(c) == 'k': - self._scroll_down(1) + if not self._scroll_down(1): + effected_lines = [self.cursel-2,self.cursel-1] elif chr(c) == 'i': cid = self._current_torrent_id() if cid: @@ -635,4 +660,4 @@ class AllTorrents(BaseMode, component.Component): for l in HELP_LINES: self.popup.add_line(l) - self.refresh() + self.refresh(effected_lines) From 5d46d2aee507db12e0827955223409d13c454572 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 1 Feb 2011 17:23:15 +0100 Subject: [PATCH 09/55] add torrentdetails state, allow state switching, move some formating to format_utils --- deluge/ui/console/main.py | 14 +- deluge/ui/console/modes/alltorrents.py | 89 +++--- deluge/ui/console/modes/basemode.py | 13 +- deluge/ui/console/modes/format_utils.py | 70 +++++ deluge/ui/console/modes/torrentdetail.py | 357 +++++++++++++++++++++++ deluge/ui/console/statusbars.py | 27 +- 6 files changed, 501 insertions(+), 69 deletions(-) create mode 100644 deluge/ui/console/modes/format_utils.py create mode 100644 deluge/ui/console/modes/torrentdetail.py diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index a0090ef15..3829e3d8f 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -48,6 +48,7 @@ import deluge.component as component from deluge.ui.client import client import deluge.common from deluge.ui.coreconfig import CoreConfig +from deluge.ui.sessionproxy import SessionProxy from deluge.ui.console.statusbars import StatusBars from deluge.ui.console.eventlog import EventLog import screen @@ -156,7 +157,10 @@ class ConsoleUI(component.Component): log.debug("Using encoding: %s", self.encoding) # Load all the commands - self._commands = load_commands(os.path.join(UI_PATH, 'commands')) + #self._commands = load_commands(os.path.join(UI_PATH, 'commands')) + + # start up the session proxy + self.sessionproxy = SessionProxy() client.set_disconnect_callback(self.on_client_disconnect) @@ -213,9 +217,9 @@ class ConsoleUI(component.Component): # We want to do an interactive session, so start up the curses screen and # pass it the function that handles commands colors.init_colors() + self.statusbars = StatusBars() from modes.alltorrents import AllTorrents self.screen = AllTorrents(stdscr, self.coreconfig, self.encoding) - self.statusbars = StatusBars() self.eventlog = EventLog() self.screen.topbar = "{!status!}Deluge " + deluge.common.get_version() + " Console" @@ -264,6 +268,12 @@ class ConsoleUI(component.Component): if not batch and self.interactive: self.screen.refresh() + def set_mode(self, mode): + reactor.removeReader(self.screen) + self.screen = mode + self.statusbars.screen = self.screen + reactor.addReader(self.screen) + def write(self, line): """ Writes a line out depending on if we're in interactive mode or not. diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 8e64e436b..f2f264225 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -46,7 +46,9 @@ from deluge.ui.sessionproxy import SessionProxy from popup import Popup,SelectablePopup,MessagePopup from add_util import add_torrent from input_popup import InputPopup +from torrentdetail import TorrentDetail +import format_utils try: import curses @@ -146,7 +148,7 @@ class StateUpdater(component.Component): self._status_cb(state,refresh) -class AllTorrents(BaseMode, component.Component): +class AllTorrents(BaseMode): def __init__(self, stdscr, coreconfig, encoding=None): self.formatted_rows = None self.cursel = 1 @@ -166,7 +168,6 @@ class AllTorrents(BaseMode, component.Component): BaseMode.__init__(self, stdscr, encoding) curses.curs_set(0) self.stdscr.notimeout(0) - self.sessionproxy = SessionProxy() self._status_fields = ["queue","name","total_wanted","state","progress","num_seeds","total_seeds", "num_peers","total_peers","download_payload_rate", "upload_payload_rate"] @@ -180,21 +181,21 @@ class AllTorrents(BaseMode, component.Component): self._info_fields = [ ("Name",None,("name",)), ("State", None, ("state",)), - ("Down Speed", self._format_speed, ("download_payload_rate",)), - ("Up Speed", self._format_speed, ("upload_payload_rate",)), - ("Progress", self._format_progress, ("progress",)), + ("Down Speed", format_utils.format_speed, ("download_payload_rate",)), + ("Up Speed", format_utils.format_speed, ("upload_payload_rate",)), + ("Progress", format_utils.format_progress, ("progress",)), ("ETA", deluge.common.ftime, ("eta",)), ("Path", None, ("save_path",)), ("Downloaded",deluge.common.fsize,("all_time_download",)), ("Uploaded", deluge.common.fsize,("total_uploaded",)), ("Share Ratio", lambda x:x < 0 and "∞" or "%.3f"%x, ("ratio",)), - ("Seeders",self._format_seeds_peers,("num_seeds","total_seeds")), - ("Peers",self._format_seeds_peers,("num_peers","total_peers")), + ("Seeders",format_utils.format_seeds_peers,("num_seeds","total_seeds")), + ("Peers",format_utils.format_seeds_peers,("num_peers","total_peers")), ("Active Time",deluge.common.ftime,("active_time",)), ("Seeding Time",deluge.common.ftime,("seeding_time",)), ("Date Added",deluge.common.fdate,("time_added",)), ("Availability", lambda x:x < 0 and "∞" or "%.3f"%x, ("distributed_copies",)), - ("Pieces", self._format_pieces, ("num_pieces","piece_length")), + ("Pieces", format_utils.format_pieces, ("num_pieces","piece_length")), ] self._status_keys = ["name","state","download_payload_rate","upload_payload_rate", @@ -203,6 +204,11 @@ class AllTorrents(BaseMode, component.Component): "seeding_time","time_added","distributed_copies", "num_pieces", "piece_length","save_path"] + def resume(self): + component.start(["AllTorrentsStateUpdater"]) + self.refresh() + + def _update_columns(self): self.column_widths = [5,-1,15,13,10,10,10,15,15] req = sum(filter(lambda x:x >= 0,self.column_widths)) @@ -220,18 +226,6 @@ class AllTorrents(BaseMode, component.Component): self.column_string = "{!header!}%s"%("".join(["%s%s"%(self.column_names[i]," "*(self.column_widths[i]-len(self.column_names[i]))) for i in range(0,len(self.column_names))])) - def _trim_string(self, string, w): - return "%s... "%(string[0:w-4]) - - def _format_column(self, col, lim): - size = len(col) - if (size >= lim - 1): - return self._trim_string(col,lim) - else: - return "%s%s"%(col," "*(lim-size)) - - def _format_row(self, row): - return "".join([self._format_column(row[i],self.column_widths[i]) for i in range(0,len(row))]) def set_state(self, state, refresh): self.curstate = state # cache in case we change sort order @@ -239,16 +233,16 @@ class AllTorrents(BaseMode, component.Component): self._sorted_ids = self._sort_torrents(self.curstate) for torrent_id in self._sorted_ids: ts = self.curstate[torrent_id] - newrows.append((self._format_row([self._format_queue(ts["queue"]), - ts["name"], - "%s"%deluge.common.fsize(ts["total_wanted"]), - ts["state"], - self._format_progress(ts["progress"]), - self._format_seeds_peers(ts["num_seeds"],ts["total_seeds"]), - self._format_seeds_peers(ts["num_peers"],ts["total_peers"]), - self._format_speed(ts["download_payload_rate"]), - self._format_speed(ts["upload_payload_rate"]) - ]),ts["state"])) + newrows.append((format_utils.format_row([self._format_queue(ts["queue"]), + ts["name"], + "%s"%deluge.common.fsize(ts["total_wanted"]), + ts["state"], + format_utils.format_progress(ts["progress"]), + format_utils.format_seeds_peers(ts["num_seeds"],ts["total_seeds"]), + format_utils.format_seeds_peers(ts["num_peers"],ts["total_peers"]), + format_utils.format_speed(ts["download_payload_rate"]), + format_utils.format_speed(ts["upload_payload_rate"]) + ],self.column_widths),ts["state"])) self.numtorrents = len(state) self.formatted_rows = newrows if refresh: @@ -328,27 +322,12 @@ class AllTorrents(BaseMode, component.Component): "sorts by queue #" return sorted(state,cmp=self._queue_sort,key=lambda s:state.get(s)["queue"]) - def _format_speed(self, speed): - if (speed > 0): - return deluge.common.fspeed(speed) - else: - return "-" - def _format_queue(self, qnum): if (qnum >= 0): return "%d"%(qnum+1) else: return "" - def _format_seeds_peers(self, num, total): - return "%d (%d)"%(num,total) - - def _format_pieces(self, num, size): - return "%d (%s)"%(num,deluge.common.fsize(size)) - - def _format_progress(self, perc): - return "%.2f%%"%perc - def _action_error(self, error): rerr = error.value self.report_message("An Error Occurred","%s got error %s: %s"%(rerr.method,rerr.exception_type,rerr.exception_msg)) @@ -475,6 +454,9 @@ class AllTorrents(BaseMode, component.Component): self.messages.append((title,message)) def refresh(self,lines=None): + #log.error("ref") + #import traceback + #traceback.print_stack() # Something has requested we scroll to the top of the list if self._go_top: self.cursel = 1 @@ -491,12 +473,12 @@ class AllTorrents(BaseMode, component.Component): # Update the status bars if self._curr_filter == None: - self.add_string(0,self.topbar) + self.add_string(0,self.statusbars.topbar) else: - self.add_string(0,"%s {!filterstatus!}Current filter: %s"%(self.topbar,self._curr_filter)) + self.add_string(0,"%s {!filterstatus!}Current filter: %s"%(self.statusbars.topbar,self._curr_filter)) self.add_string(1,self.column_string) - hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.bottombar) - 10)) - self.add_string(self.rows - 1, "%s%s"%(self.bottombar,hstr)) + hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) + self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) # add all the torrents if self.formatted_rows == []: @@ -621,6 +603,15 @@ class AllTorrents(BaseMode, component.Component): elif c == curses.KEY_NPAGE: self._scroll_down(int(self.rows/2)) + elif c == curses.KEY_RIGHT: + # We enter a new mode for the selected torrent here + if not self.marked: + component.stop(["AllTorrentsStateUpdater"]) + self.stdscr.clear() + td = TorrentDetail(self,self._current_torrent_id(),self.stdscr,self.encoding) + component.get("ConsoleUI").set_mode(td) + return + # Enter Key elif c == curses.KEY_ENTER or c == 10: self.marked.append(self.cursel) diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py index d02b2d25d..b32857ae6 100644 --- a/deluge/ui/console/modes/basemode.py +++ b/deluge/ui/console/modes/basemode.py @@ -43,6 +43,7 @@ try: except ImportError: pass +import deluge.component as component import deluge.ui.console.colors as colors try: import signal @@ -98,8 +99,7 @@ class BaseMode(CursesStdIO): self.stdscr.nodelay(1) # Strings for the 2 status bars - self.topbar = "" - self.bottombar = "" + self.statusbars = component.get("StatusBars") # Keep track of the screen size self.rows, self.cols = self.stdscr.getmaxyx() @@ -182,6 +182,10 @@ class BaseMode(CursesStdIO): screen.addstr(row, col, s, color) col += len(s) + def draw_statusbars(self): + self.add_string(0, self.statusbars.topbar) + self.add_string(self.rows - 1, self.statusbars.bottombar) + def refresh(self): """ Refreshes the screen. @@ -189,10 +193,9 @@ class BaseMode(CursesStdIO): attribute and the status bars. """ self.stdscr.clear() - + self.draw_statusbars() # Update the status bars - self.add_string(0, self.topbar) - self.add_string(self.rows - 1, self.bottombar) + self.add_string(1,"{!info!}Base Mode (or subclass hasn't overridden refresh)") self.stdscr.redrawwin() diff --git a/deluge/ui/console/modes/format_utils.py b/deluge/ui/console/modes/format_utils.py new file mode 100644 index 000000000..f3c49d6a5 --- /dev/null +++ b/deluge/ui/console/modes/format_utils.py @@ -0,0 +1,70 @@ +# format_utils.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +import deluge.common + +def format_speed(speed): + if (speed > 0): + return deluge.common.fspeed(speed) + else: + return "-" + +def format_seeds_peers(num, total): + return "%d (%d)"%(num,total) + +def format_progress(perc): + return "%.2f%%"%perc + +def format_pieces(num, size): + return "%d (%s)"%(num,deluge.common.fsize(size)) + +def format_priority(prio): + pstring = deluge.common.FILE_PRIORITY[prio] + if prio > 0: + return pstring[:pstring.index("Priority")-1] + else: + return pstring + +def trim_string(string, w): + return "%s... "%(string[0:w-4]) + +def format_column(col, lim): + size = len(col) + if (size >= lim - 1): + return trim_string(col,lim) + else: + return "%s%s"%(col," "*(lim-size)) + +def format_row(row,column_widths): + return "".join([format_column(row[i],column_widths[i]) for i in range(0,len(row))]) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py new file mode 100644 index 000000000..5f144f69d --- /dev/null +++ b/deluge/ui/console/modes/torrentdetail.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +# +# torrentdetail.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +import deluge.component as component +from basemode import BaseMode +import deluge.common +from deluge.ui.client import client + +from sys import maxint + +from deluge.ui.sessionproxy import SessionProxy + +from popup import Popup,SelectablePopup,MessagePopup +from add_util import add_torrent +from input_popup import InputPopup +import format_utils + + +try: + import curses +except ImportError: + pass + +import logging +log = logging.getLogger(__name__) + + +class TorrentDetail(BaseMode, component.Component): + def __init__(self, alltorrentmode, torrentid, stdscr, encoding=None): + self.alltorrentmode = alltorrentmode + self.torrentid = torrentid + self.torrent_state = None + self._status_keys = ["files", "name","state","download_payload_rate","upload_payload_rate", + "progress","eta","all_time_download","total_uploaded", "ratio", + "num_seeds","total_seeds","num_peers","total_peers", "active_time", + "seeding_time","time_added","distributed_copies", "num_pieces", + "piece_length","save_path","file_progress","file_priorities"] + self._info_fields = [ + ("Name",None,("name",)), + ("State", None, ("state",)), + ("Down Speed", format_utils.format_speed, ("download_payload_rate",)), + ("Up Speed", format_utils.format_speed, ("upload_payload_rate",)), + ("Progress", format_utils.format_progress, ("progress",)), + ("ETA", deluge.common.ftime, ("eta",)), + ("Path", None, ("save_path",)), + ("Downloaded",deluge.common.fsize,("all_time_download",)), + ("Uploaded", deluge.common.fsize,("total_uploaded",)), + ("Share Ratio", lambda x:x < 0 and "∞" or "%.3f"%x, ("ratio",)), + ("Seeders",format_utils.format_seeds_peers,("num_seeds","total_seeds")), + ("Peers",format_utils.format_seeds_peers,("num_peers","total_peers")), + ("Active Time",deluge.common.ftime,("active_time",)), + ("Seeding Time",deluge.common.ftime,("seeding_time",)), + ("Date Added",deluge.common.fdate,("time_added",)), + ("Availability", lambda x:x < 0 and "∞" or "%.3f"%x, ("distributed_copies",)), + ("Pieces", format_utils.format_pieces, ("num_pieces","piece_length")), + ] + self.file_list = None + self.current_file = None + self.current_file_idx = 0 + self.file_limit = maxint + self.file_off = 0 + self.more_to_draw = False + + self.column_string = "" + + BaseMode.__init__(self, stdscr, encoding) + component.Component.__init__(self, "TorrentDetail", 1, depend=["SessionProxy"]) + + self.column_names = ["Filename", "Size", "Progress", "Priority"] + self._update_columns() + + component.start(["TorrentDetail"]) + curses.curs_set(0) + self.stdscr.notimeout(0) + + # component start/update + def start(self): + component.get("SessionProxy").get_torrent_status(self.torrentid, self._status_keys).addCallback(self.set_state) + def update(self): + component.get("SessionProxy").get_torrent_status(self.torrentid, self._status_keys).addCallback(self.set_state) + + def set_state(self, state): + log.debug("got state") + if not self.file_list: + # don't keep getting the files once we've got them once + self.file_list,self.file_dict = self.build_file_list(state["files"],state["file_progress"],state["file_priorities"]) + self._status_keys.remove("files") + self._fill_progress(self.file_list,state["file_progress"]) + for i,prio in enumerate(state["file_priorities"]): + self.file_dict[i][6] = format_utils.format_priority(prio) + del state["file_progress"] + del state["file_priorities"] + self.torrent_state = state + self.refresh() + + # split file list into directory tree. this function assumes all files in a + # particular directory are returned together. it won't work otherwise. + # returned list is a list of lists of the form: + # [file/dir_name,index,size,children,expanded,progress,priority] + # for directories index will be -1, for files the value returned in the + # state object for use with other libtorrent calls (i.e. setting prio) + # + # Also returns a dictionary that maps index values to the file leaves + # for fast updating of progress and priorities + def build_file_list(self, file_tuples,prog,prio): + ret = [] + retdict = {} + for f in file_tuples: + cur = ret + ps = f["path"].split("/") + fin = ps[-1] + for p in ps: + if not cur or p != cur[-1][0]: + cl = [] + if p == fin: + ent = [p,f["index"],f["size"],cl,False, + format_utils.format_progress(prog[f["index"]]*100), + format_utils.format_priority(prio[f["index"]])] + retdict[f["index"]] = ent + else: + ent = [p,-1,-1,cl,False,"-","-"] + cur.append(ent) + cur = cl + else: + cur = cur[-1][3] + self._build_sizes(ret) + self._fill_progress(ret,prog) + return (ret,retdict) + + # fill in the sizes of the directory entries based on their children + def _build_sizes(self, fs): + ret = 0 + for f in fs: + if f[2] == -1: + val = self._build_sizes(f[3]) + ret += val + f[2] = val + else: + ret += f[2] + return ret + + # fills in progress fields in all entries based on progs + # returns the # of bytes complete in all the children of fs + def _fill_progress(self,fs,progs): + tb = 0 + for f in fs: + if f[3]: # dir, has some children + bd = self._fill_progress(f[3],progs) + f[5] = format_utils.format_progress((bd/f[2])*100) + else: # file, update own prog and add to total + bd = f[2]*progs[f[1]] + f[5] = format_utils.format_progress(progs[f[1]]*100) + tb += bd + return tb + + def _update_columns(self): + self.column_widths = [-1,15,15,20] + req = sum(filter(lambda x:x >= 0,self.column_widths)) + if (req > self.cols): # can't satisfy requests, just spread out evenly + cw = int(self.cols/len(self.column_names)) + for i in range(0,len(self.column_widths)): + self.column_widths[i] = cw + else: + rem = self.cols - req + var_cols = len(filter(lambda x: x < 0,self.column_widths)) + vw = int(rem/var_cols) + for i in range(0, len(self.column_widths)): + if (self.column_widths[i] < 0): + self.column_widths[i] = vw + + self.column_string = "{!header!}%s"%("".join(["%s%s"%(self.column_names[i]," "*(self.column_widths[i]-len(self.column_names[i]))) for i in range(0,len(self.column_names))])) + + + def draw_files(self,files,depth,off,idx): + for fl in files: + # kick out if we're going to draw too low on the screen + if (off >= self.rows-1): + self.more_to_draw = True + return -1,-1 + + self.file_limit = idx + + if idx >= self.file_off: + # set fg/bg colors based on if we are selected or not + if idx == self.current_file_idx: + self.current_file = fl + fc = "{!black,white!}" + else: + fc = "{!white,black!}" + + #actually draw the dir/file string + if fl[3] and fl[4]: # this is an expanded directory + xchar = 'v' + elif fl[3]: # collapsed directory + xchar = '>' + else: # file + xchar = '-' + + r = format_utils.format_row(["%s%s %s"%(" "*depth,xchar,fl[0]), + deluge.common.fsize(fl[2]),fl[5],fl[6]], + self.column_widths) + + self.add_string(off,"%s%s"%(fc,r),trim=False) + off += 1 + + if fl[3] and fl[4]: + # recurse if we have children and are expanded + off,idx = self.draw_files(fl[3],depth+1,off,idx+1) + if off < 0: return (off,idx) + else: + idx += 1 + + return (off,idx) + + def refresh(self,lines=None): + # Update the status bars + self.stdscr.clear() + self.add_string(0,self.statusbars.topbar) + hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) + self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) + + self.stdscr.hline((self.rows/2)-1,0,"_",self.cols) + + off = 1 + if self.torrent_state: + for f in self._info_fields: + if off >= (self.rows/2): break + if f[1] != None: + args = [] + try: + for key in f[2]: + args.append(self.torrent_state[key]) + except: + log.debug("Could not get info field: %s",e) + continue + info = f[1](*args) + else: + info = self.torrent_state[f[2][0]] + + self.add_string(off,"{!info!}%s: {!input!}%s"%(f[0],info)) + off += 1 + else: + self.add_string(1, "Waiting for torrent state") + + off = self.rows/2 + self.add_string(off,self.column_string) + if self.file_list: + off += 1 + self.more_to_draw = False + self.draw_files(self.file_list,0,off,0) + + #self.stdscr.redrawwin() + self.stdscr.noutrefresh() + + curses.doupdate() + + # expand or collapse the current file + def expcol_cur_file(self): + self.current_file[4] = not self.current_file[4] + self.refresh() + + def file_list_down(self): + if (self.current_file_idx + 1) > self.file_limit: + if self.more_to_draw: + self.current_file_idx += 1 + self.file_off += 1 + else: + return + else: + self.current_file_idx += 1 + + self.refresh() + + def file_list_up(self): + self.current_file_idx = max(0,self.current_file_idx-1) + self.file_off = min(self.file_off,self.current_file_idx) + self.refresh() + + def back_to_overview(self): + component.stop(["TorrentDetail"]) + component.deregister("TorrentDetail") + self.stdscr.clear() + component.get("ConsoleUI").set_mode(self.alltorrentmode) + self.alltorrentmode.resume() + + def _doRead(self): + c = self.stdscr.getch() + + if c > 31 and c < 256: + if chr(c) == 'Q': + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + elif chr(c) == 'q': + self.back_to_overview() + return + + if c == 27: + self.back_to_overview() + return + + # Navigate the torrent list + if c == curses.KEY_UP: + self.file_list_up() + elif c == curses.KEY_PPAGE: + pass + elif c == curses.KEY_DOWN: + self.file_list_down() + elif c == curses.KEY_NPAGE: + pass + # Enter Key + elif c == curses.KEY_ENTER or c == 10: + pass + + # space + elif c == 32: + self.expcol_cur_file() + + self.refresh() diff --git a/deluge/ui/console/statusbars.py b/deluge/ui/console/statusbars.py index 44be33cf0..9d7a09fec 100644 --- a/deluge/ui/console/statusbars.py +++ b/deluge/ui/console/statusbars.py @@ -41,7 +41,6 @@ class StatusBars(component.Component): def __init__(self): component.Component.__init__(self, "StatusBars", 2, depend=["CoreConfig"]) self.config = component.get("CoreConfig") - self.screen = component.get("ConsoleUI").screen # Hold some values we get from the core self.connections = 0 @@ -49,6 +48,10 @@ class StatusBars(component.Component): self.upload = "" self.dht = 0 + # Default values + self.topbar = "{!status!}Deluge %s Console - " % deluge.common.get_version() + self.bottombar = "{!status!}C: %s" % self.connections + def start(self): self.update() @@ -77,30 +80,28 @@ class StatusBars(component.Component): def update_statusbars(self): # Update the topbar string - self.screen.topbar = "{!status!}Deluge %s Console - " % deluge.common.get_version() + self.topbar = "{!status!}Deluge %s Console - " % deluge.common.get_version() if client.connected(): info = client.connection_info() - self.screen.topbar += "%s@%s:%s" % (info[2], info[0], info[1]) + self.topbar += "%s@%s:%s" % (info[2], info[0], info[1]) else: - self.screen.topbar += "Not Connected" + self.topbar += "Not Connected" # Update the bottombar string - self.screen.bottombar = "{!status!}C: %s" % self.connections + self.bottombar = "{!status!}C: %s" % self.connections if self.config["max_connections_global"] > -1: - self.screen.bottombar += " (%s)" % self.config["max_connections_global"] + self.bottombar += " (%s)" % self.config["max_connections_global"] - self.screen.bottombar += " D: %s/s" % self.download + self.bottombar += " D: %s/s" % self.download if self.config["max_download_speed"] > -1: - self.screen.bottombar += " (%s KiB/s)" % self.config["max_download_speed"] + self.bottombar += " (%s KiB/s)" % self.config["max_download_speed"] - self.screen.bottombar += " U: %s/s" % self.upload + self.bottombar += " U: %s/s" % self.upload if self.config["max_upload_speed"] > -1: - self.screen.bottombar += " (%s KiB/s)" % self.config["max_upload_speed"] + self.bottombar += " (%s KiB/s)" % self.config["max_upload_speed"] if self.config["dht"]: - self.screen.bottombar += " DHT: %s" % self.dht - - self.screen.refresh() + self.bottombar += " DHT: %s" % self.dht From 00fa07445263c1672085259a1115fea2f072fb50 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 1 Feb 2011 18:00:25 +0100 Subject: [PATCH 10/55] small fix for scrolling --- deluge/ui/console/modes/alltorrents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index f2f264225..98ac48e3f 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -593,11 +593,13 @@ class AllTorrents(BaseMode): # Navigate the torrent list if c == curses.KEY_UP: + if self.cursel == 1: return if not self._scroll_up(1): effected_lines = [self.cursel-1,self.cursel] elif c == curses.KEY_PPAGE: self._scroll_up(int(self.rows/2)) elif c == curses.KEY_DOWN: + if self.cursel >= self.numtorrents: return if not self._scroll_down(1): effected_lines = [self.cursel-2,self.cursel-1] elif c == curses.KEY_NPAGE: From eba7c2bf17d7a7a0203029720becb10e550310cb Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 12:44:07 +0100 Subject: [PATCH 11/55] add index value to directories in file_list --- deluge/ui/console/modes/torrentdetail.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index 5f144f69d..a39c4ea24 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -130,7 +130,8 @@ class TorrentDetail(BaseMode, component.Component): # particular directory are returned together. it won't work otherwise. # returned list is a list of lists of the form: # [file/dir_name,index,size,children,expanded,progress,priority] - # for directories index will be -1, for files the value returned in the + # for directories index values count down from maxint (for marking usage), + # for files the index is the value returned in the # state object for use with other libtorrent calls (i.e. setting prio) # # Also returns a dictionary that maps index values to the file leaves @@ -138,6 +139,7 @@ class TorrentDetail(BaseMode, component.Component): def build_file_list(self, file_tuples,prog,prio): ret = [] retdict = {} + diridx = maxint for f in file_tuples: cur = ret ps = f["path"].split("/") @@ -151,7 +153,9 @@ class TorrentDetail(BaseMode, component.Component): format_utils.format_priority(prio[f["index"]])] retdict[f["index"]] = ent else: - ent = [p,-1,-1,cl,False,"-","-"] + ent = [p,diridx,-1,cl,False,"-","-"] + retdict[diridx] = ent + diridx-=1 cur.append(ent) cur = cl else: From 5dcc93585224a8b0b5b8733112cdabadf4590c9f Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 12:50:40 +0100 Subject: [PATCH 12/55] fix for only drawing one effected line and only draw effected lines on marking --- deluge/ui/console/modes/alltorrents.py | 7 ++++--- deluge/ui/console/modes/torrentdetail.py | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 98ac48e3f..1ce09e67e 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -503,6 +503,7 @@ class AllTorrents(BaseMode): attr = None if lines: tidx = lines.pop()+1 + currow = tidx-self.curoff+2 if tidx in self.marked: bg = "blue" @@ -532,9 +533,6 @@ class AllTorrents(BaseMode): else: colorstr = "{!%s,%s!}"%(fg,bg) - if lines: - currow = tidx-self.curoff+2 - self.add_string(currow,"%s%s"%(colorstr,row[0])) tidx += 1 currow += 1 @@ -636,11 +634,14 @@ class AllTorrents(BaseMode): self.updater.set_torrent_to_update(cid,self._status_keys) elif chr(c) == 'm': self._mark_unmark(self.cursel) + effected_lines = [self.cursel-1] elif chr(c) == 'M': if self.last_mark >= 0: self.marked.extend(range(self.last_mark,self.cursel+1)) + effected_lines = range(self.last_mark,self.cursel) else: self._mark_unmark(self.cursel) + effected_lines = [self.cursel-1] elif chr(c) == 'c': self.marked = [] self.last_mark = -1 diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index a39c4ea24..d6d93ac21 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -96,6 +96,8 @@ class TorrentDetail(BaseMode, component.Component): self.column_string = "" + self.marked = {} + BaseMode.__init__(self, stdscr, encoding) component.Component.__init__(self, "TorrentDetail", 1, depend=["SessionProxy"]) From f6f3a8e0841a114b826506425cbe6f224113a2c6 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 13:09:41 +0100 Subject: [PATCH 13/55] allow marking in file view (no actions just yet) --- deluge/ui/console/modes/torrentdetail.py | 33 ++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index d6d93ac21..06204670b 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -220,12 +220,24 @@ class TorrentDetail(BaseMode, component.Component): self.file_limit = idx if idx >= self.file_off: - # set fg/bg colors based on if we are selected or not + # set fg/bg colors based on if we are selected/marked or not + + # default values + fg = "white" + bg = "black" + + if fl[1] in self.marked: + bg = "blue" + if idx == self.current_file_idx: self.current_file = fl - fc = "{!black,white!}" - else: - fc = "{!white,black!}" + bg = "white" + if fl[1] in self.marked: + fg = "blue" + else: + fg = "black" + + color_string = "{!%s,%s!}"%(fg,bg) #actually draw the dir/file string if fl[3] and fl[4]: # this is an expanded directory @@ -239,7 +251,7 @@ class TorrentDetail(BaseMode, component.Component): deluge.common.fsize(fl[2]),fl[5],fl[6]], self.column_widths) - self.add_string(off,"%s%s"%(fc,r),trim=False) + self.add_string(off,"%s%s"%(color_string,r),trim=False) off += 1 if fl[3] and fl[4]: @@ -322,6 +334,12 @@ class TorrentDetail(BaseMode, component.Component): component.get("ConsoleUI").set_mode(self.alltorrentmode) self.alltorrentmode.resume() + def _mark_unmark(self,idx): + if idx in self.marked: + del self.marked[idx] + else: + self.marked[idx] = True + def _doRead(self): c = self.stdscr.getch() @@ -359,5 +377,10 @@ class TorrentDetail(BaseMode, component.Component): # space elif c == 32: self.expcol_cur_file() + else: + if c > 31 and c < 256: + if chr(c) == 'm': + if self.current_file: + self._mark_unmark(self.current_file[1]) self.refresh() From 5f888facebdc6a7e9d41421b069d97c55d007ddb Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 13:11:15 +0100 Subject: [PATCH 14/55] handle resize in torrentdetail --- deluge/ui/console/modes/torrentdetail.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index 06204670b..a337a486c 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -63,6 +63,7 @@ class TorrentDetail(BaseMode, component.Component): self.alltorrentmode = alltorrentmode self.torrentid = torrentid self.torrent_state = None + self.popup = None self._status_keys = ["files", "name","state","download_payload_rate","upload_payload_rate", "progress","eta","all_time_download","total_uploaded", "ratio", "num_seeds","total_seeds","num_peers","total_peers", "active_time", @@ -263,6 +264,13 @@ class TorrentDetail(BaseMode, component.Component): return (off,idx) + def on_resize(self, *args): + BaseMode.on_resize_norefresh(self, *args) + self._update_columns() + if self.popup: + self.popup.handle_resize() + self.refresh() + def refresh(self,lines=None): # Update the status bars self.stdscr.clear() From d0346a104f25a4cef177badb90356dc6a1e9978f Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 13:45:17 +0100 Subject: [PATCH 15/55] don't need len() --- deluge/ui/console/modes/alltorrents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 1ce09e67e..1991b0477 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -374,7 +374,7 @@ class AllTorrents(BaseMode): def _show_torrent_actions_popup(self): #cid = self._current_torrent_id() - if len(self.marked): + if self.marked: self.popup = SelectablePopup(self,"Torrent Actions",self._torrent_action) self.popup.add_line("_Pause",data=ACTION.PAUSE) self.popup.add_line("_Resume",data=ACTION.RESUME) From d1b3aa54ad56a08c6bdb6b131ed8fde08da1530e Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 13:46:05 +0100 Subject: [PATCH 16/55] support setting file priorities in torrentdetails --- deluge/ui/console/modes/format_utils.py | 1 + deluge/ui/console/modes/torrentdetail.py | 67 +++++++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/deluge/ui/console/modes/format_utils.py b/deluge/ui/console/modes/format_utils.py index f3c49d6a5..3b73edbad 100644 --- a/deluge/ui/console/modes/format_utils.py +++ b/deluge/ui/console/modes/format_utils.py @@ -50,6 +50,7 @@ def format_pieces(num, size): return "%d (%s)"%(num,deluge.common.fsize(size)) def format_priority(prio): + if prio < 0: return "-" pstring = deluge.common.FILE_PRIORITY[prio] if prio > 0: return pstring[:pstring.index("Priority")-1] diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index a337a486c..3b784a829 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -57,7 +57,6 @@ except ImportError: import logging log = logging.getLogger(__name__) - class TorrentDetail(BaseMode, component.Component): def __init__(self, alltorrentmode, torrentid, stdscr, encoding=None): self.alltorrentmode = alltorrentmode @@ -123,7 +122,7 @@ class TorrentDetail(BaseMode, component.Component): self._status_keys.remove("files") self._fill_progress(self.file_list,state["file_progress"]) for i,prio in enumerate(state["file_priorities"]): - self.file_dict[i][6] = format_utils.format_priority(prio) + self.file_dict[i][6] = prio del state["file_progress"] del state["file_priorities"] self.torrent_state = state @@ -153,10 +152,10 @@ class TorrentDetail(BaseMode, component.Component): if p == fin: ent = [p,f["index"],f["size"],cl,False, format_utils.format_progress(prog[f["index"]]*100), - format_utils.format_priority(prio[f["index"]])] + prio[f["index"]]] retdict[f["index"]] = ent else: - ent = [p,diridx,-1,cl,False,"-","-"] + ent = [p,diridx,-1,cl,False,0,-1] retdict[diridx] = ent diridx-=1 cur.append(ent) @@ -249,7 +248,8 @@ class TorrentDetail(BaseMode, component.Component): xchar = '-' r = format_utils.format_row(["%s%s %s"%(" "*depth,xchar,fl[0]), - deluge.common.fsize(fl[2]),fl[5],fl[6]], + deluge.common.fsize(fl[2]),fl[5], + format_utils.format_priority(fl[6])], self.column_widths) self.add_string(off,"%s%s"%(color_string,r),trim=False) @@ -311,6 +311,9 @@ class TorrentDetail(BaseMode, component.Component): #self.stdscr.redrawwin() self.stdscr.noutrefresh() + if self.popup: + self.popup.refresh() + curses.doupdate() # expand or collapse the current file @@ -342,6 +345,48 @@ class TorrentDetail(BaseMode, component.Component): component.get("ConsoleUI").set_mode(self.alltorrentmode) self.alltorrentmode.resume() + # build list of priorities for all files in the torrent + # based on what is currently selected and a selected priority. + def build_prio_list(self, files, ret_list, parent_prio, selected_prio): + # has a priority been set on my parent (if so, I inherit it) + for f in files: + if f[3]: # dir, check if i'm setting on whole dir, then recurse + if f[1] in self.marked: # marked, recurse and update all children with new prio + parent_prio = selected_prio + self.build_prio_list(f[3],ret_list,parent_prio,selected_prio) + parent_prio = -1 + else: # not marked, just recurse + self.build_prio_list(f[3],ret_list,parent_prio,selected_prio) + else: # file, need to add to list + if f[1] in self.marked or parent_prio >= 0: + # selected (or parent selected), use requested priority + ret_list.append((f[1],selected_prio)) + else: + # not selected, just keep old priority + ret_list.append((f[1],f[6])) + + def do_priority(self, idx, data): + plist = [] + self.build_prio_list(self.file_list,plist,-1,data) + plist.sort() + priorities = [p[1] for p in plist] + log.debug("priorities: %s", priorities) + + client.core.set_torrent_file_priorities(self.torrentid, priorities) + + if len(self.marked) == 1: + self.marked = {} + return True + + # show popup for priority selections + def show_priority_popup(self): + if self.marked: + self.popup = SelectablePopup(self,"Torrent Actions",self.do_priority) + self.popup.add_line("_Do Not Download",data=deluge.common.FILE_PRIORITY["Do Not Download"]) + self.popup.add_line("_Normal Priority",data=deluge.common.FILE_PRIORITY["Normal Priority"]) + self.popup.add_line("_High Priority",data=deluge.common.FILE_PRIORITY["High Priority"]) + self.popup.add_line("H_ighest Priority",data=deluge.common.FILE_PRIORITY["Highest Priority"]) + def _mark_unmark(self,idx): if idx in self.marked: del self.marked[idx] @@ -351,6 +396,12 @@ class TorrentDetail(BaseMode, component.Component): def _doRead(self): c = self.stdscr.getch() + if self.popup: + if self.popup.handle_read(c): + self.popup = None + self.refresh() + return + if c > 31 and c < 256: if chr(c) == 'Q': from twisted.internet import reactor @@ -378,9 +429,11 @@ class TorrentDetail(BaseMode, component.Component): self.file_list_down() elif c == curses.KEY_NPAGE: pass + # Enter Key elif c == curses.KEY_ENTER or c == 10: - pass + self.marked[self.current_file[1]] = True + self.show_priority_popup() # space elif c == 32: @@ -390,5 +443,7 @@ class TorrentDetail(BaseMode, component.Component): if chr(c) == 'm': if self.current_file: self._mark_unmark(self.current_file[1]) + if chr(c) == 'c': + self.marked = {} self.refresh() From ad498c6e424869f37d9c7125af28efa646e6ff7e Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 13:57:31 +0100 Subject: [PATCH 17/55] Revert "remove special case white/black pair. doesn't seem needed and breaks white,black,attrs" This does actually seem to break some terminals This reverts commit ba3a093746a51fd46b9b0bcaf72847549e0c94b3. --- deluge/ui/console/colors.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deluge/ui/console/colors.py b/deluge/ui/console/colors.py index 9988ab882..7d7944b3c 100644 --- a/deluge/ui/console/colors.py +++ b/deluge/ui/console/colors.py @@ -50,7 +50,9 @@ colors = [ ] # {(fg, bg): pair_number, ...} -color_pairs = {} +color_pairs = { + ("white", "black"): 0 # Special case, can't be changed +} # Some default color schemes schemes = { @@ -91,6 +93,8 @@ def init_colors(): counter = 1 for fg in colors: for bg in colors: + if fg == "COLOR_WHITE" and bg == "COLOR_BLACK": + continue color_pairs[(fg[6:].lower(), bg[6:].lower())] = counter curses.init_pair(counter, getattr(curses, fg), getattr(curses, bg)) counter += 1 From b35875e300e6938a33080df87119bc22af3bfae8 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 16:21:52 +0100 Subject: [PATCH 18/55] attempted fix of color/underline issue. this is a bit of a hack, and seems to work some places, but not everywhere --- deluge/ui/console/colors.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deluge/ui/console/colors.py b/deluge/ui/console/colors.py index 7d7944b3c..bc092373c 100644 --- a/deluge/ui/console/colors.py +++ b/deluge/ui/console/colors.py @@ -99,6 +99,14 @@ def init_colors(): curses.init_pair(counter, getattr(curses, fg), getattr(curses, bg)) counter += 1 + # try to redefine white/black as it makes underlining work for some terminals + # but can also fail on others, so we try/except + try: + curses.init_pair(counter, curses.COLOR_WHITE, curses.COLOR_BLACK) + color_pairs[("white","black")] = counter + except: + pass + class BadColorString(Exception): pass From 78ea5c9bd39d9829cae0700b4feb876d633ea702 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 18:32:03 +0100 Subject: [PATCH 19/55] don't enter torrentdetails if nothing is selected --- deluge/ui/console/modes/alltorrents.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 1991b0477..5a201e89c 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -606,11 +606,13 @@ class AllTorrents(BaseMode): elif c == curses.KEY_RIGHT: # We enter a new mode for the selected torrent here if not self.marked: - component.stop(["AllTorrentsStateUpdater"]) - self.stdscr.clear() - td = TorrentDetail(self,self._current_torrent_id(),self.stdscr,self.encoding) - component.get("ConsoleUI").set_mode(td) - return + tid = self._current_torrent_id() + if tid: + component.stop(["AllTorrentsStateUpdater"]) + self.stdscr.clear() + td = TorrentDetail(self,self._current_torrent_id(),self.stdscr,self.encoding) + component.get("ConsoleUI").set_mode(td) + return # Enter Key elif c == curses.KEY_ENTER or c == 10: From 6c8529b3ba2b26540d0dda459a8ae3d6aa77485a Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 20:39:57 +0100 Subject: [PATCH 20/55] updated file seperator --- deluge/ui/console/modes/torrentdetail.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index 3b784a829..ec8d8adaf 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -95,6 +95,7 @@ class TorrentDetail(BaseMode, component.Component): self.more_to_draw = False self.column_string = "" + self.files_sep = None self.marked = {} @@ -118,6 +119,7 @@ class TorrentDetail(BaseMode, component.Component): log.debug("got state") if not self.file_list: # don't keep getting the files once we've got them once + self.files_sep = "{!green,black,bold,underline!}%s"%(("Files (torrent has %d files)"%len(state["files"])).center(self.cols)) self.file_list,self.file_dict = self.build_file_list(state["files"],state["file_progress"],state["file_priorities"]) self._status_keys.remove("files") self._fill_progress(self.file_list,state["file_progress"]) @@ -207,7 +209,7 @@ class TorrentDetail(BaseMode, component.Component): if (self.column_widths[i] < 0): self.column_widths[i] = vw - self.column_string = "{!header!}%s"%("".join(["%s%s"%(self.column_names[i]," "*(self.column_widths[i]-len(self.column_names[i]))) for i in range(0,len(self.column_names))])) + self.column_string = "{!green,black,bold!}%s"%("".join(["%s%s"%(self.column_names[i]," "*(self.column_widths[i]-len(self.column_names[i]))) for i in range(0,len(self.column_names))])) def draw_files(self,files,depth,off,idx): @@ -278,7 +280,8 @@ class TorrentDetail(BaseMode, component.Component): hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) - self.stdscr.hline((self.rows/2)-1,0,"_",self.cols) + if self.files_sep: + self.add_string((self.rows/2)-1,self.files_sep) off = 1 if self.torrent_state: From 1952357f3559e4847a5f7577d45a01c8b70e9c78 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 20:40:15 +0100 Subject: [PATCH 21/55] update help --- deluge/ui/console/modes/alltorrents.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 5a201e89c..934296f26 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -93,6 +93,10 @@ The actions you can perform and the keys to perform them are as follows: and last marked torrent 'c' - Un-mark all torrents +Right Arrow - Show torrent details. This includes more detailed information + about the currently selected torrent, as well as a view of the + files in the torrent and the ability to set file priorities. + Enter - Show torrent actions popup. Here you can do things like pause/resume, remove, recheck and so one. These actions apply to all currently marked torrents. The currently From b41ebe1b89cd54e5b24c596b530f45a1ccab24f3 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 20:49:11 +0100 Subject: [PATCH 22/55] don't show action popup if there are no torrents in the view --- deluge/ui/console/modes/alltorrents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 934296f26..fbf36fd67 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -619,7 +619,7 @@ class AllTorrents(BaseMode): return # Enter Key - elif c == curses.KEY_ENTER or c == 10: + elif (c == curses.KEY_ENTER or c == 10) and self.numtorrents: self.marked.append(self.cursel) self.last_mark = self.cursel self._show_torrent_actions_popup() From 0353a388b3541e35003b5c385948c5d2ea1bf8ef Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 20:49:27 +0100 Subject: [PATCH 23/55] add option to action popup for torrent details --- deluge/ui/console/modes/alltorrents.py | 30 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index fbf36fd67..b6f686974 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -117,6 +117,8 @@ class ACTION: REMOVE_DATA=6 REMOVE_NODATA=7 + DETAILS=8 + class FILTER: ALL=0 ACTIVE=1 @@ -332,6 +334,13 @@ class AllTorrents(BaseMode): else: return "" + + def show_torrent_details(self,tid): + component.stop(["AllTorrentsStateUpdater"]) + self.stdscr.clear() + td = TorrentDetail(self,tid,self.stdscr,self.encoding) + component.get("ConsoleUI").set_mode(td) + def _action_error(self, error): rerr = error.value self.report_message("An Error Occurred","%s got error %s: %s"%(rerr.method,rerr.exception_type,rerr.exception_msg)) @@ -371,6 +380,13 @@ class AllTorrents(BaseMode): elif data==ACTION.REANNOUNCE: log.debug("Reannouncing torrents: %s",ids) client.core.force_reannounce(ids).addErrback(self._action_error) + elif data==ACTION.DETAILS: + log.debug("Torrent details") + tid = self._current_torrent_id() + if tid: + self.show_torrent_details(tid) + else: + log.error("No current torrent in _torrent_action, this is a bug") if len(ids) == 1: self.marked = [] self.last_mark = -1 @@ -387,6 +403,8 @@ class AllTorrents(BaseMode): self.popup.add_divider() self.popup.add_line("Remo_ve Torrent",data=ACTION.REMOVE) self.popup.add_line("_Force Recheck",data=ACTION.RECHECK) + self.popup.add_divider() + self.popup.add_line("Torrent _Details",data=ACTION.DETAILS) def _torrent_filter(self, idx, data): if data==FILTER.ALL: @@ -609,14 +627,10 @@ class AllTorrents(BaseMode): elif c == curses.KEY_RIGHT: # We enter a new mode for the selected torrent here - if not self.marked: - tid = self._current_torrent_id() - if tid: - component.stop(["AllTorrentsStateUpdater"]) - self.stdscr.clear() - td = TorrentDetail(self,self._current_torrent_id(),self.stdscr,self.encoding) - component.get("ConsoleUI").set_mode(td) - return + tid = self._current_torrent_id() + if tid: + self.show_torrent_details(tid) + return # Enter Key elif (c == curses.KEY_ENTER or c == 10) and self.numtorrents: From db6474586255345e6175701a678b2100b480afeb Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 2 Feb 2011 20:50:48 +0100 Subject: [PATCH 24/55] fix priority popup title --- deluge/ui/console/modes/torrentdetail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index ec8d8adaf..fe09d30c3 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -384,7 +384,7 @@ class TorrentDetail(BaseMode, component.Component): # show popup for priority selections def show_priority_popup(self): if self.marked: - self.popup = SelectablePopup(self,"Torrent Actions",self.do_priority) + self.popup = SelectablePopup(self,"Set File Priority",self.do_priority) self.popup.add_line("_Do Not Download",data=deluge.common.FILE_PRIORITY["Do Not Download"]) self.popup.add_line("_Normal Priority",data=deluge.common.FILE_PRIORITY["Normal Priority"]) self.popup.add_line("_High Priority",data=deluge.common.FILE_PRIORITY["High Priority"]) From f748660cac0c48b65885491400fef000275c9e8d Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 7 Feb 2011 15:00:51 +0100 Subject: [PATCH 25/55] show status message --- deluge/ui/console/modes/torrentdetail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index fe09d30c3..9701d1d6d 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -67,10 +67,11 @@ class TorrentDetail(BaseMode, component.Component): "progress","eta","all_time_download","total_uploaded", "ratio", "num_seeds","total_seeds","num_peers","total_peers", "active_time", "seeding_time","time_added","distributed_copies", "num_pieces", - "piece_length","save_path","file_progress","file_priorities"] + "piece_length","save_path","file_progress","file_priorities","message"] self._info_fields = [ ("Name",None,("name",)), ("State", None, ("state",)), + ("Status",None,("message",)), ("Down Speed", format_utils.format_speed, ("download_payload_rate",)), ("Up Speed", format_utils.format_speed, ("upload_payload_rate",)), ("Progress", format_utils.format_progress, ("progress",)), From 183a97785b2bb7c6d9cacbb67c0a412bd77ba4fa Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 7 Feb 2011 15:01:07 +0100 Subject: [PATCH 26/55] split off torrent actions popup --- deluge/ui/console/modes/alltorrents.py | 95 ++++---------------------- deluge/ui/console/modes/basemode.py | 12 ++++ 2 files changed, 26 insertions(+), 81 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index b6f686974..eeef41a92 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -47,6 +47,7 @@ from popup import Popup,SelectablePopup,MessagePopup from add_util import add_torrent from input_popup import InputPopup from torrentdetail import TorrentDetail +from torrent_actions import torrent_actions_popup import format_utils @@ -106,19 +107,6 @@ Enter - Show torrent actions popup. Here you can do things like """ HELP_LINES = HELP_STR.split('\n') -class ACTION: - PAUSE=0 - RESUME=1 - REANNOUNCE=2 - EDIT_TRACKERS=3 - RECHECK=4 - REMOVE=5 - - REMOVE_DATA=6 - REMOVE_NODATA=7 - - DETAILS=8 - class FILTER: ALL=0 ACTIVE=1 @@ -268,7 +256,7 @@ class AllTorrents(BaseMode): self.curoff = self.cursel - self.rows + 5 return prevoff != self.curoff - def _current_torrent_id(self): + def current_torrent_id(self): if self._sorted_ids: return self._sorted_ids[self.cursel-1] else: @@ -341,70 +329,6 @@ class AllTorrents(BaseMode): td = TorrentDetail(self,tid,self.stdscr,self.encoding) component.get("ConsoleUI").set_mode(td) - def _action_error(self, error): - rerr = error.value - self.report_message("An Error Occurred","%s got error %s: %s"%(rerr.method,rerr.exception_type,rerr.exception_msg)) - self.refresh() - - def _torrent_action(self, idx, data): - log.error("Action %d",data) - ids = self._selected_torrent_ids() - if ids: - if data==ACTION.PAUSE: - log.debug("Pausing torrents: %s",ids) - client.core.pause_torrent(ids).addErrback(self._action_error) - elif data==ACTION.RESUME: - log.debug("Resuming torrents: %s", ids) - client.core.resume_torrent(ids).addErrback(self._action_error) - elif data==ACTION.REMOVE: - def do_remove(tid,data): - ids = self._selected_torrent_ids() - if data: - wd = data==ACTION.REMOVE_DATA - for tid in ids: - log.debug("Removing torrent: %s,%d",tid,wd) - client.core.remove_torrent(tid,wd).addErrback(self._action_error) - if len(ids) == 1: - self.marked = [] - self.last_mark = -1 - return True - self.popup = SelectablePopup(self,"Confirm Remove",do_remove) - self.popup.add_line("Are you sure you want to remove the marked torrents?",selectable=False) - self.popup.add_line("Remove with _data",data=ACTION.REMOVE_DATA) - self.popup.add_line("Remove _torrent",data=ACTION.REMOVE_NODATA) - self.popup.add_line("_Cancel",data=0) - return False - elif data==ACTION.RECHECK: - log.debug("Rechecking torrents: %s", ids) - client.core.force_recheck(ids).addErrback(self._action_error) - elif data==ACTION.REANNOUNCE: - log.debug("Reannouncing torrents: %s",ids) - client.core.force_reannounce(ids).addErrback(self._action_error) - elif data==ACTION.DETAILS: - log.debug("Torrent details") - tid = self._current_torrent_id() - if tid: - self.show_torrent_details(tid) - else: - log.error("No current torrent in _torrent_action, this is a bug") - if len(ids) == 1: - self.marked = [] - self.last_mark = -1 - return True - - def _show_torrent_actions_popup(self): - #cid = self._current_torrent_id() - if self.marked: - self.popup = SelectablePopup(self,"Torrent Actions",self._torrent_action) - self.popup.add_line("_Pause",data=ACTION.PAUSE) - self.popup.add_line("_Resume",data=ACTION.RESUME) - self.popup.add_divider() - self.popup.add_line("_Update Tracker",data=ACTION.REANNOUNCE) - self.popup.add_divider() - self.popup.add_line("Remo_ve Torrent",data=ACTION.REMOVE) - self.popup.add_line("_Force Recheck",data=ACTION.RECHECK) - self.popup.add_divider() - self.popup.add_line("Torrent _Details",data=ACTION.DETAILS) def _torrent_filter(self, idx, data): if data==FILTER.ALL: @@ -475,6 +399,14 @@ class AllTorrents(BaseMode): def report_message(self,title,message): self.messages.append((title,message)) + def clear_marks(self): + self.marked = [] + self.last_mark = -1 + + def set_popup(self,pu): + self.popup = pu + self.refresh() + def refresh(self,lines=None): #log.error("ref") #import traceback @@ -627,7 +559,7 @@ class AllTorrents(BaseMode): elif c == curses.KEY_RIGHT: # We enter a new mode for the selected torrent here - tid = self._current_torrent_id() + tid = self.current_torrent_id() if tid: self.show_torrent_details(tid) return @@ -636,7 +568,8 @@ class AllTorrents(BaseMode): elif (c == curses.KEY_ENTER or c == 10) and self.numtorrents: self.marked.append(self.cursel) self.last_mark = self.cursel - self._show_torrent_actions_popup() + torrent_actions_popup(self,self._selected_torrent_ids(),details=True) + return else: if c > 31 and c < 256: @@ -647,7 +580,7 @@ class AllTorrents(BaseMode): if not self._scroll_down(1): effected_lines = [self.cursel-2,self.cursel-1] elif chr(c) == 'i': - cid = self._current_torrent_id() + cid = self.current_torrent_id() if cid: self.popup = Popup(self,"Info",close_cb=lambda:self.updater.set_torrent_to_update(None,None)) self.popup.add_line("Getting torrent info...") diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py index b32857ae6..24cb3cca4 100644 --- a/deluge/ui/console/modes/basemode.py +++ b/deluge/ui/console/modes/basemode.py @@ -186,6 +186,18 @@ class BaseMode(CursesStdIO): self.add_string(0, self.statusbars.topbar) self.add_string(self.rows - 1, self.statusbars.bottombar) + # This mode doesn't report errors + def report_message(self): + pass + + # This mode doesn't do anything with popups + def set_popup(self,popup): + pass + + # This mode doesn't support marking + def clear_marks(self): + pass + def refresh(self): """ Refreshes the screen. From e1d8025309eea849e9836fd71e7d7adfa967ed10 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 7 Feb 2011 15:10:18 +0100 Subject: [PATCH 27/55] show torrent actions form details when 'a' pressed --- deluge/ui/console/modes/torrentdetail.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index 9701d1d6d..6beaa7ed2 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -40,6 +40,7 @@ import deluge.common from deluge.ui.client import client from sys import maxint +from collections import deque from deluge.ui.sessionproxy import SessionProxy @@ -48,6 +49,7 @@ from add_util import add_torrent from input_popup import InputPopup import format_utils +from torrent_actions import torrent_actions_popup try: import curses @@ -63,6 +65,7 @@ class TorrentDetail(BaseMode, component.Component): self.torrentid = torrentid self.torrent_state = None self.popup = None + self.messages = deque() self._status_keys = ["files", "name","state","download_payload_rate","upload_payload_rate", "progress","eta","all_time_download","total_uploaded", "ratio", "num_seeds","total_seeds","num_peers","total_peers", "active_time", @@ -212,6 +215,17 @@ class TorrentDetail(BaseMode, component.Component): self.column_string = "{!green,black,bold!}%s"%("".join(["%s%s"%(self.column_names[i]," "*(self.column_widths[i]-len(self.column_names[i]))) for i in range(0,len(self.column_names))])) + + def report_message(self,title,message): + self.messages.append((title,message)) + + def clear_marks(self): + self.marked = {} + + def set_popup(self,pu): + self.popup = pu + self.refresh() + def draw_files(self,files,depth,off,idx): for fl in files: @@ -275,6 +289,11 @@ class TorrentDetail(BaseMode, component.Component): self.refresh() def refresh(self,lines=None): + # show a message popup if there's anything queued + if self.popup == None and self.messages: + title,msg = self.messages.popleft() + self.popup = MessagePopup(self,title,msg) + # Update the status bars self.stdscr.clear() self.add_string(0,self.statusbars.topbar) @@ -449,5 +468,8 @@ class TorrentDetail(BaseMode, component.Component): self._mark_unmark(self.current_file[1]) if chr(c) == 'c': self.marked = {} + if chr(c) == 'a': + torrent_actions_popup(self,[self.torrentid],details=False) + return self.refresh() From b77f8929d69a773457828f60c852885fa5314dec Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 7 Feb 2011 15:10:41 +0100 Subject: [PATCH 28/55] oops, add torrent actions new file --- deluge/ui/console/modes/torrent_actions.py | 114 +++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 deluge/ui/console/modes/torrent_actions.py diff --git a/deluge/ui/console/modes/torrent_actions.py b/deluge/ui/console/modes/torrent_actions.py new file mode 100644 index 000000000..c8501e8a2 --- /dev/null +++ b/deluge/ui/console/modes/torrent_actions.py @@ -0,0 +1,114 @@ +# torrent_actions.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +from deluge.ui.client import client +from popup import SelectablePopup + +import logging +log = logging.getLogger(__name__) + +class ACTION: + PAUSE=0 + RESUME=1 + REANNOUNCE=2 + EDIT_TRACKERS=3 + RECHECK=4 + REMOVE=5 + + REMOVE_DATA=6 + REMOVE_NODATA=7 + + DETAILS=8 + +def action_error(error,mode): + rerr = error.value + mode.report_message("An Error Occurred","%s got error %s: %s"%(rerr.method,rerr.exception_type,rerr.exception_msg)) + mode.refresh() + +def torrent_action(idx, data, mode, ids): + if ids: + if data==ACTION.PAUSE: + log.debug("Pausing torrents: %s",ids) + client.core.pause_torrent(ids).addErrback(action_error,mode) + elif data==ACTION.RESUME: + log.debug("Resuming torrents: %s", ids) + client.core.resume_torrent(ids).addErrback(action_error,mode) + elif data==ACTION.REMOVE: + def do_remove(idx,data,mode,ids): + if data: + wd = data==ACTION.REMOVE_DATA + for tid in ids: + log.debug("Removing torrent: %s,%d",tid,wd) + client.core.remove_torrent(tid,wd).addErrback(action_error,mode) + if len(ids) == 1: + mode.clear_marks() + return True + popup = SelectablePopup(mode,"Confirm Remove",do_remove,mode,ids) + popup.add_line("Are you sure you want to remove the marked torrents?",selectable=False) + popup.add_line("Remove with _data",data=ACTION.REMOVE_DATA) + popup.add_line("Remove _torrent",data=ACTION.REMOVE_NODATA) + popup.add_line("_Cancel",data=0) + mode.set_popup(popup) + return False + elif data==ACTION.RECHECK: + log.debug("Rechecking torrents: %s", ids) + client.core.force_recheck(ids).addErrback(action_error,mode) + elif data==ACTION.REANNOUNCE: + log.debug("Reannouncing torrents: %s",ids) + client.core.force_reannounce(ids).addErrback(action_error,mode) + elif data==ACTION.DETAILS: + log.debug("Torrent details") + tid = mode.current_torrent_id() + if tid: + mode.show_torrent_details(tid) + else: + log.error("No current torrent in _torrent_action, this is a bug") + if len(ids) == 1: + mode.clear_marks() + return True + +# Creates the popup. mode is the calling mode, tids is a list of torrents to take action upon +def torrent_actions_popup(mode,tids,details=False): + popup = SelectablePopup(mode,"Torrent Actions",torrent_action,mode,tids) + popup.add_line("_Pause",data=ACTION.PAUSE) + popup.add_line("_Resume",data=ACTION.RESUME) + popup.add_divider() + popup.add_line("_Update Tracker",data=ACTION.REANNOUNCE) + popup.add_divider() + popup.add_line("Remo_ve Torrent",data=ACTION.REMOVE) + popup.add_line("_Force Recheck",data=ACTION.RECHECK) + if details: + popup.add_divider() + popup.add_line("Torrent _Details",data=ACTION.DETAILS) + mode.set_popup(popup) From cd7805bfda2f67e7fd9bd9cd4dd55b8d3d8254ad Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 8 Feb 2011 14:43:41 +0100 Subject: [PATCH 29/55] make text input not go over width --- deluge/ui/console/modes/input_popup.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py index da6139b13..72b9991e8 100644 --- a/deluge/ui/console/modes/input_popup.py +++ b/deluge/ui/console/modes/input_popup.py @@ -109,9 +109,17 @@ class TextInput(InputField): if selected: if self.opts: self.parent.add_string(row+2,self.opts[self.opt_off:],screen,1,False,True) - self.move_func(row+1,self.cursor+1) + if self.cursor > (width-3): + self.move_func(row+1,width-2) + else: + self.move_func(row+1,self.cursor+1) self.parent.add_string(row,self.message,screen,1,False,True) - self.parent.add_string(row+1,"{!black,white,bold!}%s"%self.value.ljust(width-2),screen,1,False,False) + slen = len(self.value)+3 + if slen > width: + vstr = self.value[(slen-width):] + else: + vstr = self.value.ljust(width-2) + self.parent.add_string(row+1,"{!black,white,bold!}%s"%vstr,screen,1,False,False) return 3 From cdcab320fbf6603fe6b7500918270b94d13f2c1b Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 10 Feb 2011 01:11:31 +0100 Subject: [PATCH 30/55] add current_selection to SelectablePopup --- deluge/ui/console/modes/popup.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py index bd5bf7858..7cc468a1d 100644 --- a/deluge/ui/console/modes/popup.py +++ b/deluge/ui/console/modes/popup.py @@ -206,6 +206,11 @@ class SelectablePopup(Popup): self.parent.add_string(crow,"- %s%s"%(colorstr,line),self.screen,1,False,True) crow+=1 + def current_selection(self): + "Returns a tuple of (selected index, selected data)" + idx = self._selectable_lines.index(self._selected) + return (idx,self._select_data[idx]) + def add_divider(self,color="white"): if not self._divider: self._divider = "-"*(self.width-6)+" -" From 7f6a1db89a4f266ae6bd71c28dcbe50a1d32a656 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 10 Feb 2011 01:12:14 +0100 Subject: [PATCH 31/55] start with a connection manager instead of just alltorrents --- deluge/ui/console/main.py | 40 ++-- deluge/ui/console/modes/alltorrents.py | 4 +- deluge/ui/console/modes/connectionmanager.py | 219 +++++++++++++++++++ 3 files changed, 241 insertions(+), 22 deletions(-) create mode 100644 deluge/ui/console/modes/connectionmanager.py diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index 3829e3d8f..0e6dfef28 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -171,28 +171,28 @@ class ConsoleUI(component.Component): self.interactive = False # Try to connect to the localhost daemon - def on_connect(result): - def on_started(result): - if not self.interactive: - def on_started(result): - deferreds = [] - # If we have args, lets process them and quit - # allow multiple commands split by ";" - for arg in args.split(";"): - deferreds.append(defer.maybeDeferred(self.do_command, arg.strip())) + # def on_connect(result): + # def on_started(result): + # if not self.interactive: + # def on_started(result): + # deferreds = [] + # # If we have args, lets process them and quit + # # allow multiple commands split by ";" + # for arg in args.split(";"): + # deferreds.append(defer.maybeDeferred(self.do_command, arg.strip())) - def on_complete(result): - self.do_command("quit") + # def on_complete(result): + # self.do_command("quit") - dl = defer.DeferredList(deferreds).addCallback(on_complete) + # dl = defer.DeferredList(deferreds).addCallback(on_complete) - # We need to wait for the rpcs in start() to finish before processing - # any of the commands. - self.started_deferred.addCallback(on_started) - component.start().addCallback(on_started) + # # We need to wait for the rpcs in start() to finish before processing + # # any of the commands. + # self.started_deferred.addCallback(on_started) + # component.start().addCallback(on_started) - d = client.connect() - d.addCallback(on_connect) + #d = client.connect() + #d.addCallback(on_connect) self.coreconfig = CoreConfig() if self.interactive and not deluge.common.windows_check(): @@ -218,8 +218,8 @@ class ConsoleUI(component.Component): # pass it the function that handles commands colors.init_colors() self.statusbars = StatusBars() - from modes.alltorrents import AllTorrents - self.screen = AllTorrents(stdscr, self.coreconfig, self.encoding) + from modes.connectionmanager import ConnectionManager + self.screen = ConnectionManager(stdscr, self.encoding) self.eventlog = EventLog() self.screen.topbar = "{!status!}Deluge " + deluge.common.get_version() + " Console" diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index eeef41a92..d75bcf90f 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -143,7 +143,7 @@ class StateUpdater(component.Component): class AllTorrents(BaseMode): - def __init__(self, stdscr, coreconfig, encoding=None): + def __init__(self, stdscr, encoding=None): self.formatted_rows = None self.cursel = 1 self.curoff = 1 # TODO: this should really be 0 indexed @@ -157,7 +157,7 @@ class AllTorrents(BaseMode): self._curr_filter = None - self.coreconfig = coreconfig + self.coreconfig = component.get("ConsoleUI").coreconfig BaseMode.__init__(self, stdscr, encoding) curses.curs_set(0) diff --git a/deluge/ui/console/modes/connectionmanager.py b/deluge/ui/console/modes/connectionmanager.py new file mode 100644 index 000000000..4f9b4686a --- /dev/null +++ b/deluge/ui/console/modes/connectionmanager.py @@ -0,0 +1,219 @@ +# +# connectionmanager.py +# +# Copyright (C) 2007-2009 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +# a mode that show's a popup to select which host to connect to + +import hashlib,time + +from collections import deque + +import deluge.ui.client +from deluge.ui.client import client +from deluge.configmanager import ConfigManager +from deluge.ui.coreconfig import CoreConfig +import deluge.component as component + +from alltorrents import AllTorrents +from basemode import BaseMode +from popup import SelectablePopup,MessagePopup +from input_popup import InputPopup + + +try: + import curses +except ImportError: + pass + +import logging +log = logging.getLogger(__name__) + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 58846 + +DEFAULT_CONFIG = { + "hosts": [(hashlib.sha1(str(time.time())).hexdigest(), DEFAULT_HOST, DEFAULT_PORT, "", "")] +} + + +class ConnectionManager(BaseMode): + def __init__(self, stdscr, encoding=None): + self.popup = None + self.statuses = {} + self.messages = deque() + self.config = ConfigManager("hostlist.conf.1.2", DEFAULT_CONFIG) + BaseMode.__init__(self, stdscr, encoding) + self.__update_statuses() + self.__update_popup() + + def __update_popup(self): + self.popup = SelectablePopup(self,"Select Host",self.__host_selected) + self.popup.add_line("{!white,black,bold!}'Q'=quit, 'r'=refresh, 'a'=add new host, 'D'=delete host",selectable=False) + for host in self.config["hosts"]: + if host[0] in self.statuses: + self.popup.add_line("%s:%d [Online] (%s)"%(host[1],host[2],self.statuses[host[0]]),data=host[0],foreground="green") + else: + self.popup.add_line("%s:%d [Offline]"%(host[1],host[2]),data=host[0],foreground="red") + self.inlist = True + self.refresh() + + def __update_statuses(self): + """Updates the host status""" + def on_connect(result, c, host_id): + def on_info(info, c): + self.statuses[host_id] = info + self.__update_popup() + c.disconnect() + + def on_info_fail(reason, c): + if host_id in self.statuses: + del self.statuses[host_id] + c.disconnect() + + d = c.daemon.info() + d.addCallback(on_info, c) + d.addErrback(on_info_fail, c) + + def on_connect_failed(reason, host_id): + if host_id in self.statuses: + del self.statuses[host_id] + + for host in self.config["hosts"]: + c = deluge.ui.client.Client() + hadr = host[1] + port = host[2] + user = host[3] + password = host[4] + d = c.connect(hadr, port, user, password) + d.addCallback(on_connect, c, host[0]) + d.addErrback(on_connect_failed, host[0]) + + def __on_connected(self,result): + component.start() + self.stdscr.clear() + at = AllTorrents(self.stdscr, self.encoding) + component.get("ConsoleUI").set_mode(at) + at.resume() + + def __host_selected(self, idx, data): + for host in self.config["hosts"]: + if host[0] == data and host[0] in self.statuses: + client.connect(host[1], host[2], host[3], host[4]).addCallback(self.__on_connected) + return False + + def __do_add(self,result): + hostname = result["hostname"] + try: + port = int(result["port"]) + except ValueError: + self.report_message("Can't add host","Invalid port. Must be an integer") + return + username = result["username"] + password = result["password"] + for host in self.config["hosts"]: + if (host[1],host[2],host[3]) == (hostname, port, username): + self.report_message("Can't add host","Host already in list") + return + newid = hashlib.sha1(str(time.time())).hexdigest() + self.config["hosts"].append((newid, hostname, port, username, password)) + self.config.save() + self.__update_popup() + + def __add_popup(self): + self.inlist = False + self.popup = InputPopup(self,"Add Host (esc to cancel)",close_cb=self.__do_add) + self.popup.add_text_input("Hostname:","hostname") + self.popup.add_text_input("Port:","port") + self.popup.add_text_input("Username:","username") + self.popup.add_text_input("Password:","password") + self.refresh() + + def __delete_current_host(self): + idx,data = self.popup.current_selection() + log.debug("deleting host: %s",data) + for host in self.config["hosts"]: + if host[0] == data: + self.config["hosts"].remove(host) + break + self.config.save() + + def report_message(self,title,message): + self.messages.append((title,message)) + + def refresh(self): + self.stdscr.clear() + self.draw_statusbars() + self.stdscr.noutrefresh() + + if self.popup == None and self.messages: + title,msg = self.messages.popleft() + self.popup = MessagePopup(self,title,msg) + + if not self.popup: + self.__update_popup() + + self.popup.refresh() + curses.doupdate() + + + def _doRead(self): + # Read the character + c = self.stdscr.getch() + + if c > 31 and c < 256: + if chr(c) == 'q' and self.inlist: return + if chr(c) == 'Q': + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + if chr(c) == 'D' and self.inlist: + self.__delete_current_host() + self.__update_popup() + return + if chr(c) == 'r' and self.inlist: + self.__update_statuses() + if chr(c) == 'a' and self.inlist: + self.__add_popup() + return + + if self.popup: + if self.popup.handle_read(c): + self.popup = None + self.refresh() + return From 9e5455793bb474998bf65a7b73dc649524f032dc Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 10 Feb 2011 16:45:10 +0100 Subject: [PATCH 32/55] remove unused code this breaks command line right now, will put it back in later --- deluge/ui/console/main.py | 310 +------------------------------------- 1 file changed, 2 insertions(+), 308 deletions(-) diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index 0e6dfef28..bd2f8c657 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -43,7 +43,6 @@ import locale from twisted.internet import defer, reactor -from deluge.ui.console import UI_PATH import deluge.component as component from deluge.ui.client import client import deluge.common @@ -51,7 +50,7 @@ from deluge.ui.coreconfig import CoreConfig from deluge.ui.sessionproxy import SessionProxy from deluge.ui.console.statusbars import StatusBars from deluge.ui.console.eventlog import EventLog -import screen +#import screen import colors from deluge.ui.ui import _UI @@ -63,11 +62,6 @@ class Console(_UI): def __init__(self): super(Console, self).__init__("console") - cmds = load_commands(os.path.join(UI_PATH, 'commands')) - - group = optparse.OptionGroup(self.parser, "Console Commands", - "\n".join(cmds.keys())) - self.parser.add_option_group(group) def start(self): super(Console, self).start() @@ -93,62 +87,11 @@ class OptionParser(optparse.OptionParser): """ raise Exception(msg) -class BaseCommand(object): - - usage = 'usage' - option_list = tuple() - aliases = [] - - def complete(self, text, *args): - return [] - def handle(self, *args, **options): - pass - - @property - def name(self): - return 'base' - - @property - def epilog(self): - return self.__doc__ - - def split(self, text): - if deluge.common.windows_check(): - text = text.replace('\\', '\\\\') - return shlex.split(text) - - def create_parser(self): - return OptionParser(prog = self.name, - usage = self.usage, - epilog = self.epilog, - option_list = self.option_list) - -def load_commands(command_dir, exclude=[]): - def get_command(name): - return getattr(__import__('deluge.ui.console.commands.%s' % name, {}, {}, ['Command']), 'Command')() - - try: - commands = [] - for filename in os.listdir(command_dir): - if filename.split('.')[0] in exclude or filename.startswith('_'): - continue - if not (filename.endswith('.py') or filename.endswith('.pyc')): - continue - cmd = get_command(filename.split('.')[len(filename.split('.')) - 2]) - aliases = [ filename.split('.')[len(filename.split('.')) - 2] ] - aliases.extend(cmd.aliases) - for a in aliases: - commands.append((a, cmd)) - return dict(commands) - except OSError, e: - return {} class ConsoleUI(component.Component): def __init__(self, args=None): component.Component.__init__(self, "ConsoleUI", 2) - self.batch_write = False - try: locale.setlocale(locale.LC_ALL, '') self.encoding = locale.getpreferredencoding() @@ -156,8 +99,7 @@ class ConsoleUI(component.Component): self.encoding = sys.getdefaultencoding() log.debug("Using encoding: %s", self.encoding) - # Load all the commands - #self._commands = load_commands(os.path.join(UI_PATH, 'commands')) + # start up the session proxy self.sessionproxy = SessionProxy() @@ -170,30 +112,6 @@ class ConsoleUI(component.Component): args = args[0] self.interactive = False - # Try to connect to the localhost daemon - # def on_connect(result): - # def on_started(result): - # if not self.interactive: - # def on_started(result): - # deferreds = [] - # # If we have args, lets process them and quit - # # allow multiple commands split by ";" - # for arg in args.split(";"): - # deferreds.append(defer.maybeDeferred(self.do_command, arg.strip())) - - # def on_complete(result): - # self.do_command("quit") - - # dl = defer.DeferredList(deferreds).addCallback(on_complete) - - # # We need to wait for the rpcs in start() to finish before processing - # # any of the commands. - # self.started_deferred.addCallback(on_started) - # component.start().addCallback(on_started) - - #d = client.connect() - #d.addCallback(on_connect) - self.coreconfig = CoreConfig() if self.interactive and not deluge.common.windows_check(): # We use the curses.wrapper function to prevent the console from getting @@ -233,40 +151,6 @@ class ConsoleUI(component.Component): # Start the twisted mainloop reactor.run() - def start(self): - # This gets fired once we have received the torrents list from the core - self.started_deferred = defer.Deferred() - - # Maintain a list of (torrent_id, name) for use in tab completion - self.torrents = [] - def on_session_state(result): - def on_torrents_status(torrents): - for torrent_id, status in torrents.items(): - self.torrents.append((torrent_id, status["name"])) - self.started_deferred.callback(True) - - client.core.get_torrents_status({"id": result}, ["name"]).addCallback(on_torrents_status) - client.core.get_session_state().addCallback(on_session_state) - - # Register some event handlers to keep the torrent list up-to-date - client.register_event_handler("TorrentAddedEvent", self.on_torrent_added_event) - client.register_event_handler("TorrentRemovedEvent", self.on_torrent_removed_event) - - def update(self): - pass - - def set_batch_write(self, batch): - """ - When this is set the screen is not refreshed after a `:meth:write` until - this is set to False. - - :param batch: set True to prevent screen refreshes after a `:meth:write` - :type batch: bool - - """ - self.batch_write = batch - if not batch and self.interactive: - self.screen.refresh() def set_mode(self, mode): reactor.removeReader(self.screen) @@ -274,195 +158,5 @@ class ConsoleUI(component.Component): self.statusbars.screen = self.screen reactor.addReader(self.screen) - def write(self, line): - """ - Writes a line out depending on if we're in interactive mode or not. - - :param line: str, the line to print - - """ - if self.interactive: - #self.screen.add_line(line, not self.batch_write) - pass - else: - print(colors.strip_colors(line)) - - def do_command(self, cmd): - """ - Processes a command. - - :param cmd: str, the command string - - """ - if not cmd: - return - cmd, _, line = cmd.partition(' ') - try: - parser = self._commands[cmd].create_parser() - except KeyError: - self.write("{!error!}Unknown command: %s" % cmd) - return - args = self._commands[cmd].split(line) - - # Do a little hack here to print 'command --help' properly - parser._print_help = parser.print_help - def print_help(f=None): - if self.interactive: - self.write(parser.format_help()) - else: - parser._print_help(f) - parser.print_help = print_help - - # Only these commands can be run when not connected to a daemon - not_connected_cmds = ["help", "connect", "quit"] - aliases = [] - for c in not_connected_cmds: - aliases.extend(self._commands[c].aliases) - not_connected_cmds.extend(aliases) - - if not client.connected() and cmd not in not_connected_cmds: - self.write("{!error!}Not connected to a daemon, please use the connect command first.") - return - - try: - options, args = parser.parse_args(args) - except Exception, e: - self.write("{!error!}Error parsing options: %s" % e) - return - - if not getattr(options, '_exit', False): - try: - ret = self._commands[cmd].handle(*args, **options.__dict__) - except Exception, e: - self.write("{!error!}" + str(e)) - log.exception(e) - import traceback - self.write("%s" % traceback.format_exc()) - return defer.succeed(True) - else: - return ret - - def tab_completer(self, line, cursor, second_hit): - """ - Called when the user hits 'tab' and will autocomplete or show options. - If a command is already supplied in the line, this function will call the - complete method of the command. - - :param line: str, the current input string - :param cursor: int, the cursor position in the line - :param second_hit: bool, if this is the second time in a row the tab key - has been pressed - - :returns: 2-tuple (string, cursor position) - - """ - # First check to see if there is no space, this will mean that it's a - # command that needs to be completed. - if " " not in line: - possible_matches = [] - # Iterate through the commands looking for ones that startwith the - # line. - for cmd in self._commands: - if cmd.startswith(line): - possible_matches.append(cmd + " ") - - line_prefix = "" - else: - cmd = line.split(" ")[0] - if cmd in self._commands: - # Call the command's complete method to get 'er done - possible_matches = self._commands[cmd].complete(line.split(" ")[-1]) - line_prefix = " ".join(line.split(" ")[:-1]) + " " - else: - # This is a bogus command - return (line, cursor) - - # No matches, so just return what we got passed - if len(possible_matches) == 0: - return (line, cursor) - # If we only have 1 possible match, then just modify the line and - # return it, else we need to print out the matches without modifying - # the line. - elif len(possible_matches) == 1: - new_line = line_prefix + possible_matches[0] - return (new_line, len(new_line)) - else: - if second_hit: - # Only print these out if it's a second_hit - self.write(" ") - for match in possible_matches: - self.write(match) - else: - p = " ".join(line.split(" ")[:-1]) - new_line = " ".join([p, os.path.commonprefix(possible_matches)]) - if len(new_line) > len(line): - line = new_line - cursor = len(line) - return (line, cursor) - - def tab_complete_torrent(self, line): - """ - Completes torrent_ids or names. - - :param line: str, the string to complete - - :returns: list of matches - - """ - - possible_matches = [] - - # Find all possible matches - for torrent_id, torrent_name in self.torrents: - if torrent_id.startswith(line): - possible_matches.append(torrent_id + " ") - if torrent_name.startswith(line): - possible_matches.append(torrent_name + " ") - - return possible_matches - - def get_torrent_name(self, torrent_id): - """ - Gets a torrent name from the torrents list. - - :param torrent_id: str, the torrent_id - - :returns: the name of the torrent or None - """ - - for tid, name in self.torrents: - if torrent_id == tid: - return name - - return None - - def match_torrent(self, string): - """ - Returns a list of torrent_id matches for the string. It will search both - torrent_ids and torrent names, but will only return torrent_ids. - - :param string: str, the string to match on - - :returns: list of matching torrent_ids. Will return an empty list if - no matches are found. - - """ - ret = [] - for tid, name in self.torrents: - if tid.startswith(string) or name.startswith(string): - ret.append(tid) - - return ret - - def on_torrent_added_event(self, event): - def on_torrent_status(status): - self.torrents.append((event.torrent_id, status["name"])) - client.core.get_torrent_status(event.torrent_id, ["name"]).addCallback(on_torrent_status) - - def on_torrent_removed_event(self, event): - for index, (tid, name) in enumerate(self.torrents): - if event.torrent_id == tid: - del self.torrents[index] - def on_client_disconnect(self): component.stop() From 20302021c4eebe82b12e136bb5964424ac1675d0 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 10 Feb 2011 17:39:42 +0100 Subject: [PATCH 33/55] initial prefs mode. doesn't do anything yet --- deluge/ui/console/modes/alltorrents.py | 9 ++ deluge/ui/console/modes/preferences.py | 191 +++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 deluge/ui/console/modes/preferences.py diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index d75bcf90f..a45ee7875 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -47,6 +47,7 @@ from popup import Popup,SelectablePopup,MessagePopup from add_util import add_torrent from input_popup import InputPopup from torrentdetail import TorrentDetail +from preferences import Preferences from torrent_actions import torrent_actions_popup import format_utils @@ -329,6 +330,11 @@ class AllTorrents(BaseMode): td = TorrentDetail(self,tid,self.stdscr,self.encoding) component.get("ConsoleUI").set_mode(td) + def show_prefrences(self): + component.stop(["AllTorrentsStateUpdater"]) + self.stdscr.clear() + prefs = Preferences(self,self.stdscr,self.encoding) + component.get("ConsoleUI").set_mode(prefs) def _torrent_filter(self, idx, data): if data==FILTER.ALL: @@ -606,5 +612,8 @@ class AllTorrents(BaseMode): self.popup = Popup(self,"Help") for l in HELP_LINES: self.popup.add_line(l) + elif chr(c) == 'p': + self.show_prefrences() + return self.refresh(effected_lines) diff --git a/deluge/ui/console/modes/preferences.py b/deluge/ui/console/modes/preferences.py new file mode 100644 index 000000000..58d509d95 --- /dev/null +++ b/deluge/ui/console/modes/preferences.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# preferences.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +import deluge.component as component +from basemode import BaseMode + +from collections import deque + +try: + import curses +except ImportError: + pass + + +import logging +log = logging.getLogger(__name__) + +class ZONE: + CATEGORIES = 0 + PREFRENCES = 1 + ACTIONS = 2 + +class Preferences(BaseMode): + def __init__(self, parent_mode, stdscr, encoding=None): + self.parent_mode = parent_mode + self.categories = [_("Downloads"), _("Network"), _("Bandwidth"), + _("Interface"), _("Other"), _("Daemon"), _("Queue"), _("Proxy"), + _("Cache")] # , _("Plugins")] + self.cur_cat = 0 + self.cur_action = 0 + self.popup = None + self.messages = deque() + + self.active_zone = ZONE.CATEGORIES + + # how wide is the left 'pane' with categories + self.div_off = 15 + + BaseMode.__init__(self, stdscr, encoding) + + def __draw_catetories(self): + for i,category in enumerate(self.categories): + if i == self.cur_cat and self.active_zone == ZONE.CATEGORIES: + self.add_string(i+1,"- {!black,white,bold!}%s"%category,pad=False) + elif i == self.cur_cat: + self.add_string(i+1,"- {!black,white!}%s"%category,pad=False) + else: + self.add_string(i+1,"- %s"%category) + self.stdscr.vline(1,self.div_off,'|',self.rows-2) + + def __draw_actions(self): + c = self.cols-22 + self.stdscr.hline(self.rows-3,self.div_off+1,"_",self.cols) + if self.active_zone != ZONE.ACTIONS: + self.add_string(self.rows-2,"[Cancel] [Apply] [OK]",col=c) + else: + if self.cur_action == 0: + self.add_string(self.rows-2,"[{!black,white,bold!}Cancel{!white,black!}] [Apply] [OK]",col=c) + elif self.cur_action == 1: + self.add_string(self.rows-2,"[Cancel] [{!black,white,bold!}Apply{!white,black!}] [OK]",col=c) + elif self.cur_action == 2: + self.add_string(self.rows-2,"[Cancel] [Apply] [{!black,white,bold!}OK{!white,black!}]",col=c) + + + def refresh(self): + if self.popup == None and self.messages: + title,msg = self.messages.popleft() + self.popup = MessagePopup(self,title,msg) + + self.stdscr.clear() + self.add_string(0,self.statusbars.topbar) + hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) + self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) + + self.__draw_catetories() + self.__draw_actions() + + self.stdscr.noutrefresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def __category_read(self, c): + # Navigate prefs + if c == curses.KEY_UP: + self.cur_cat = max(0,self.cur_cat-1) + elif c == curses.KEY_DOWN: + self.cur_cat = min(len(self.categories)-1,self.cur_cat+1) + + def __prefs_read(self, c): + pass + + def __actions_read(self, c): + # Navigate actions + if c == curses.KEY_LEFT: + self.cur_action = max(0,self.cur_action-1) + elif c == curses.KEY_RIGHT: + self.cur_action = min(2,self.cur_action+1) + elif c == curses.KEY_ENTER or c == 10: + # take action + if self.cur_action == 0: # cancel + self.back_to_parent() + elif self.cur_action == 1: # apply + # TODO: Actually apply + pass + elif self.cur_action == 2: # OK + # TODO: Actually apply + self.back_to_parent() + + + def back_to_parent(self): + self.stdscr.clear() + component.get("ConsoleUI").set_mode(self.parent_mode) + self.parent_mode.resume() + + def _doRead(self): + c = self.stdscr.getch() + + if self.popup: + if self.popup.handle_read(c): + self.popup = None + self.refresh() + return + + if c > 31 and c < 256: + if chr(c) == 'Q': + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + + elif c == 9: + self.active_zone += 1 + if self.active_zone > ZONE.ACTIONS: + self.active_zone = ZONE.CATEGORIES + + elif c == curses.KEY_BTAB: + self.active_zone -= 1 + if self.active_zone < ZONE.CATEGORIES: + self.active_zone = ZONE.ACTIONS + + else: + if self.active_zone == ZONE.CATEGORIES: + self.__category_read(c) + elif self.active_zone == ZONE.PREFRENCES: + self.__prefs_read(c) + elif self.active_zone == ZONE.ACTIONS: + self.__actions_read(c) + + self.refresh() + + From 00ab9ff49985c34147884caa47af347af35effbc Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 10 Feb 2011 17:47:50 +0100 Subject: [PATCH 34/55] support offset text/select inputs and select inputs with no message --- deluge/ui/console/modes/input_popup.py | 29 +++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py index 72b9991e8..459637d78 100644 --- a/deluge/ui/console/modes/input_popup.py +++ b/deluge/ui/console/modes/input_popup.py @@ -50,7 +50,7 @@ log = logging.getLogger(__name__) class InputField: # render the input. return number of rows taken up - def render(self,screen,row,width,selected): + def render(self,screen,row,width,selected,col=1): return 0 def handle_read(self, c): if c in [curses.KEY_ENTER, 10, 127, 113]: @@ -67,18 +67,23 @@ class SelectInput(InputField): self.opts = opts self.selidx = selidx - def render(self, screen, row, width, selected): - self.parent.add_string(row,self.message,screen,1,False,True) - off = 2 + def render(self, screen, row, width, selected, col=1): + if self.message: + self.parent.add_string(row,self.message,screen,col,False,True) + row += 1 + off = col+1 for i,opt in enumerate(self.opts): if selected and i == self.selidx: - self.parent.add_string(row+1,"{!black,white,bold!}[%s]"%opt,screen,off,False,True) + self.parent.add_string(row,"{!black,white,bold!}[%s]"%opt,screen,off,False,True) elif i == self.selidx: - self.parent.add_string(row+1,"[{!white,black,underline!}%s{!white,black!}]"%opt,screen,off,False,True) + self.parent.add_string(row,"[{!white,black,underline!}%s{!white,black!}]"%opt,screen,off,False,True) else: - self.parent.add_string(row+1,"[%s]"%opt,screen,off,False,True) + self.parent.add_string(row,"[%s]"%opt,screen,off,False,True) off += len(opt)+3 - return 2 + if self.message: + return 2 + else: + return 1 def handle_read(self, c): if c == curses.KEY_LEFT: @@ -105,21 +110,21 @@ class TextInput(InputField): self.opts = None self.opt_off = 0 - def render(self,screen,row,width,selected): + def render(self,screen,row,width,selected,col=1): if selected: if self.opts: - self.parent.add_string(row+2,self.opts[self.opt_off:],screen,1,False,True) + self.parent.add_string(row+2,self.opts[self.opt_off:],screen,col,False,True) if self.cursor > (width-3): self.move_func(row+1,width-2) else: self.move_func(row+1,self.cursor+1) - self.parent.add_string(row,self.message,screen,1,False,True) + self.parent.add_string(row,self.message,screen,col,False,True) slen = len(self.value)+3 if slen > width: vstr = self.value[(slen-width):] else: vstr = self.value.ljust(width-2) - self.parent.add_string(row+1,"{!black,white,bold!}%s"%vstr,screen,1,False,False) + self.parent.add_string(row+1,"{!black,white,bold!}%s"%vstr,screen,col,False,False) return 3 From 077f35ec5c8733fd214ecb4ebcb1a71717dbaee1 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 10 Feb 2011 17:54:59 +0100 Subject: [PATCH 35/55] use selection input for cancel/apply/ok --- deluge/ui/console/modes/preferences.py | 36 ++++++++++---------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/deluge/ui/console/modes/preferences.py b/deluge/ui/console/modes/preferences.py index 58d509d95..32b3c4c4f 100644 --- a/deluge/ui/console/modes/preferences.py +++ b/deluge/ui/console/modes/preferences.py @@ -36,6 +36,8 @@ import deluge.component as component from basemode import BaseMode +from input_popup import SelectInput + from collections import deque @@ -60,9 +62,9 @@ class Preferences(BaseMode): _("Interface"), _("Other"), _("Daemon"), _("Queue"), _("Proxy"), _("Cache")] # , _("Plugins")] self.cur_cat = 0 - self.cur_action = 0 self.popup = None self.messages = deque() + self.action_input = None self.active_zone = ZONE.CATEGORIES @@ -70,6 +72,8 @@ class Preferences(BaseMode): self.div_off = 15 BaseMode.__init__(self, stdscr, encoding) + self.action_input = SelectInput(self,None,None,["Cancel","Apply","OK"],0) + self.refresh() def __draw_catetories(self): for i,category in enumerate(self.categories): @@ -82,18 +86,10 @@ class Preferences(BaseMode): self.stdscr.vline(1,self.div_off,'|',self.rows-2) def __draw_actions(self): - c = self.cols-22 - self.stdscr.hline(self.rows-3,self.div_off+1,"_",self.cols) - if self.active_zone != ZONE.ACTIONS: - self.add_string(self.rows-2,"[Cancel] [Apply] [OK]",col=c) - else: - if self.cur_action == 0: - self.add_string(self.rows-2,"[{!black,white,bold!}Cancel{!white,black!}] [Apply] [OK]",col=c) - elif self.cur_action == 1: - self.add_string(self.rows-2,"[Cancel] [{!black,white,bold!}Apply{!white,black!}] [OK]",col=c) - elif self.cur_action == 2: - self.add_string(self.rows-2,"[Cancel] [Apply] [{!black,white,bold!}OK{!white,black!}]",col=c) - + if self.action_input: + selected = self.active_zone == ZONE.ACTIONS + self.stdscr.hline(self.rows-3,self.div_off+1,"_",self.cols) + self.action_input.render(self.stdscr,self.rows-2,self.cols,selected,self.cols-22) def refresh(self): if self.popup == None and self.messages: @@ -126,19 +122,15 @@ class Preferences(BaseMode): pass def __actions_read(self, c): - # Navigate actions - if c == curses.KEY_LEFT: - self.cur_action = max(0,self.cur_action-1) - elif c == curses.KEY_RIGHT: - self.cur_action = min(2,self.cur_action+1) - elif c == curses.KEY_ENTER or c == 10: + self.action_input.handle_read(c) + if c == curses.KEY_ENTER or c == 10: # take action - if self.cur_action == 0: # cancel + if self.action_input.selidx == 0: # cancel self.back_to_parent() - elif self.cur_action == 1: # apply + elif self.action_input.selidx == 1: # apply # TODO: Actually apply pass - elif self.cur_action == 2: # OK + elif self.action_input.selidx == 2: # OK # TODO: Actually apply self.back_to_parent() From 23f64a54407225d7878be4dc143639452df8122c Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 14 Feb 2011 12:25:04 +0100 Subject: [PATCH 36/55] add back write method and keep track of events for (future) event view --- deluge/ui/console/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index bd2f8c657..cb252d3fb 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -92,6 +92,9 @@ class ConsoleUI(component.Component): def __init__(self, args=None): component.Component.__init__(self, "ConsoleUI", 2) + # keep track of events for the log view + self.events = [] + try: locale.setlocale(locale.LC_ALL, '') self.encoding = locale.getpreferredencoding() @@ -160,3 +163,6 @@ class ConsoleUI(component.Component): def on_client_disconnect(self): component.stop() + + def write(self, s): + self.events.append(s) From ac8c928a5b976dedf06f33ea2dcf1013ab7d09c7 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 14 Feb 2011 12:25:43 +0100 Subject: [PATCH 37/55] don't always refresh on __init__ --- deluge/ui/console/modes/basemode.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py index 24cb3cca4..2dd9b87d7 100644 --- a/deluge/ui/console/modes/basemode.py +++ b/deluge/ui/console/modes/basemode.py @@ -72,7 +72,7 @@ class CursesStdIO(object): class BaseMode(CursesStdIO): - def __init__(self, stdscr, encoding=None): + def __init__(self, stdscr, encoding=None, do_refresh=True): """ A mode that provides a curses screen designed to run as a reader in a twisted reactor. This mode doesn't do much, just shows status bars and "Base Mode" on the screen @@ -116,7 +116,8 @@ class BaseMode(CursesStdIO): colors.init_colors() # Do a refresh right away to draw the screen - self.refresh() + if do_refresh: + self.refresh() def on_resize_norefresh(self, *args): log.debug("on_resize_from_signal") From 77eb1a5f8295c9677a69075a912f94c1d24a2a1d Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 14 Feb 2011 12:26:24 +0100 Subject: [PATCH 38/55] prefs actually work. some tweaks to inputs to better support prefs not all prefs available yet --- deluge/ui/console/modes/alltorrents.py | 9 +- deluge/ui/console/modes/input_popup.py | 129 +++++++++++- deluge/ui/console/modes/preference_panes.py | 212 ++++++++++++++++++++ deluge/ui/console/modes/preferences.py | 74 +++++-- 4 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 deluge/ui/console/modes/preference_panes.py diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index a45ee7875..049103666 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -330,10 +330,10 @@ class AllTorrents(BaseMode): td = TorrentDetail(self,tid,self.stdscr,self.encoding) component.get("ConsoleUI").set_mode(td) - def show_prefrences(self): + def show_preferences(self, core_config): component.stop(["AllTorrentsStateUpdater"]) self.stdscr.clear() - prefs = Preferences(self,self.stdscr,self.encoding) + prefs = Preferences(self,core_config,self.stdscr,self.encoding) component.get("ConsoleUI").set_mode(prefs) def _torrent_filter(self, idx, data): @@ -376,7 +376,6 @@ class AllTorrents(BaseMode): self.popup.add_line("Q_ueued",data=FILTER.QUEUED,foreground="yellow") def _do_add(self, result): - result["add_paused"] = (result["add_paused"] == "Yes") log.debug("Adding Torrent: %s (dl path: %s) (paused: %d)",result["file"],result["path"],result["add_paused"]) def suc_cb(msg): self.report_message("Torrent Added",msg) @@ -400,7 +399,7 @@ class AllTorrents(BaseMode): self.popup = InputPopup(self,"Add Torrent (Esc to cancel)",close_cb=self._do_add) self.popup.add_text_input("Enter path to torrent file:","file") self.popup.add_text_input("Enter save path:","path",dl) - self.popup.add_select_input("Add Paused:","add_paused",["Yes","No"],ap) + self.popup.add_select_input("Add Paused:","add_paused",["Yes","No"],[True,False],ap) def report_message(self,title,message): self.messages.append((title,message)) @@ -613,7 +612,7 @@ class AllTorrents(BaseMode): for l in HELP_LINES: self.popup.add_line(l) elif chr(c) == 'p': - self.show_prefrences() + client.core.get_config().addCallback(self.show_preferences) return self.refresh(effected_lines) diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py index 459637d78..9c1dc4547 100644 --- a/deluge/ui/console/modes/input_popup.py +++ b/deluge/ui/console/modes/input_popup.py @@ -58,13 +58,121 @@ class InputField: return False def get_value(self): return None + def set_value(self, value): + pass + +class CheckedInput(InputField): + def __init__(self, parent, message, name, checked=False): + self.parent = parent + self.chkd_inact = "[X] %s"%message + self.unchkd_inact = "[ ] %s"%message + self.chkd_act = "[{!black,white,bold!}X{!white,black!}] %s"%message + self.unchkd_act = "[{!black,white,bold!} {!white,black!}] %s"%message + self.name = name + self.checked = checked + + def render(self, screen, row, width, active, col=1): + if self.checked and active: + self.parent.add_string(row,self.chkd_act,screen,col,False,True) + elif self.checked: + self.parent.add_string(row,self.chkd_inact,screen,col,False,True) + elif active: + self.parent.add_string(row,self.unchkd_act,screen,col,False,True) + else: + self.parent.add_string(row,self.unchkd_inact,screen,col,False,True) + return 1 + + def handle_read(self, c): + if c == 32: + self.checked = not self.checked + + def get_value(self): + return self.checked + + def set_value(self, c): + self.checked = c + +class IntSpinInput(InputField): + def __init__(self, parent, message, name, move_func, value, min_val, max_val): + self.parent = parent + self.message = message + self.name = name + self.value = int(value) + self.initvalue = self.value + self.valstr = "%d"%self.value + self.cursor = len(self.valstr) + self.cursoff = len(self.message)+4 # + 4 for the " [ " in the rendered string + self.move_func = move_func + self.min_val = min_val + self.max_val = max_val + + def render(self, screen, row, width, active, col=1): + if not active and not self.valstr: + self.value = self.initvalue + self.valstr = "%d"%self.value + self.cursor = len(self.valstr) + if not self.valstr: + self.parent.add_string(row,"%s [ ]"%self.message,screen,col,False,True) + elif active: + self.parent.add_string(row,"%s [ {!black,white,bold!}%d{!white,black!} ]"%(self.message,self.value),screen,col,False,True) + else: + self.parent.add_string(row,"%s [ %d ]"%(self.message,self.value),screen,col,False,True) + + if active: + self.move_func(row,self.cursor+self.cursoff) + + return 1 + + def handle_read(self, c): + if c == curses.KEY_PPAGE: + self.value+=1 + elif c == curses.KEY_NPAGE: + self.value-=1 + elif c == curses.KEY_LEFT: + self.cursor = max(0,self.cursor-1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.valstr),self.cursor+1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.value) + elif c == curses.KEY_BACKSPACE or c == 127: + if self.valstr and self.cursor > 0: + self.valstr = self.valstr[:self.cursor - 1] + self.valstr[self.cursor:] + self.cursor-=1 + if self.valstr: + self.value = int(self.valstr) + elif c == curses.KEY_DC: + if self.valstr and self.cursor < len(self.valstr): + self.valstr = self.valstr[:self.cursor] + self.valstr[self.cursor+1:] + elif c > 47 and c < 58: + if c == 48 and self.cursor == 0: return + if self.cursor == len(self.valstr): + self.valstr += chr(c) + self.value = int(self.valstr) + else: + # Insert into string + self.valstr = self.valstr[:self.cursor] + chr(c) + self.valstr[self.cursor:] + self.value = int(self.valstr) + # Move the cursor forward + self.cursor+=1 + + + def get_value(self): + return self.value + + def set_value(self, val): + self.value = int(val) + self.valstr = "%d"%self.value + self.cursor = len(self.valstr) class SelectInput(InputField): - def __init__(self, parent, message, name, opts, selidx): + def __init__(self, parent, message, name, opts, vals, selidx): self.parent = parent self.message = message self.name = name self.opts = opts + self.vals = vals self.selidx = selidx def render(self, screen, row, width, selected, col=1): @@ -92,7 +200,14 @@ class SelectInput(InputField): self.selidx = min(len(self.opts)-1,self.selidx+1) def get_value(self): - return self.opts[self.selidx] + return self.vals[self.selidx] + + def set_value(self, nv): + for i,val in enumerate(self.vals): + if nv == val: + self.selidx = i + return + raise Exception("Invalid value for SelectInput") class TextInput(InputField): def __init__(self, parent, move_func, width, message, name, value, docmp): @@ -106,7 +221,7 @@ class TextInput(InputField): self.docmp = docmp self.tab_count = 0 - self.cursor = 0 + self.cursor = len(self.value) self.opts = None self.opt_off = 0 @@ -131,6 +246,10 @@ class TextInput(InputField): def get_value(self): return self.value + def set_value(self,val): + self.value = val + self.cursor = len(self.value) + # most of the cursor,input stuff here taken from ui/console/screen.py def handle_read(self,c): if c == 9 and self.docmp: @@ -264,8 +383,8 @@ class InputPopup(Popup): self.inputs.append(TextInput(self.parent, self.move, self.width, message, name, value, complete)) - def add_select_input(self, message, name, opts, default_index=0): - self.inputs.append(SelectInput(self.parent, message, name, opts, default_index)) + def add_select_input(self, message, name, opts, vals, default_index=0): + self.inputs.append(SelectInput(self.parent, message, name, opts, vals, default_index)) def _refresh_lines(self): self._cursor_row = -1 diff --git a/deluge/ui/console/modes/preference_panes.py b/deluge/ui/console/modes/preference_panes.py new file mode 100644 index 000000000..572adb5a4 --- /dev/null +++ b/deluge/ui/console/modes/preference_panes.py @@ -0,0 +1,212 @@ +# +# preference_panes.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +from deluge.ui.console.modes.input_popup import TextInput,SelectInput,CheckedInput,IntSpinInput + +try: + import curses +except ImportError: + pass + +import logging +log = logging.getLogger(__name__) + + +class Header: + def __init__(self, parent, header, space_above, space_below): + self.parent = parent + self.header = "{!white,black,bold!}%s"%header + self.space_above = space_above + self.space_below = space_below + + def render(self, screen, row, width, active, offset): + rows = 1 + if self.space_above: + row += 1 + rows += 1 + self.parent.add_string(row,self.header,screen,offset-1,False,True) + if self.space_below: rows += 1 + return rows + + +class BasePane: + def __init__(self, offset, parent, width): + self.offset = offset+1 + self.parent = parent + self.width = width + self.inputs = [] + self.active_input = -1 + + def move(self,r,c): + self._cursor_row = r + self._cursor_col = c + + def add_config_values(self,conf_dict): + for ipt in self.inputs: + if not isinstance(ipt,Header): + conf_dict[ipt.name] = ipt.get_value() + + def update_values(self, conf_dict): + for ipt in self.inputs: + if not isinstance(ipt,Header): + try: + ipt.set_value(conf_dict[ipt.name]) + except KeyError: # just ignore if it's not in dict + pass + + def render(self, mode, screen, width, active): + self._cursor_row = -1 + if self.active_input < 0: + for i,ipt in enumerate(self.inputs): + if not isinstance(ipt,Header): + self.active_input = i + break + crow = 1 + for i,ipt in enumerate(self.inputs): + act = active and i==self.active_input + crow += ipt.render(screen,crow,width, act, self.offset) + + if active and self._cursor_row >= 0: + curses.curs_set(2) + screen.move(self._cursor_row,self._cursor_col+self.offset-1) + else: + curses.curs_set(0) + + # just handles setting the active input + def handle_read(self,c): + if not self.inputs: # no inputs added yet + return + + if c == curses.KEY_UP: + nc = max(0,self.active_input-1) + while isinstance(self.inputs[nc], Header): + nc-=1 + if nc <= 0: break + if not isinstance(self.inputs[nc], Header): + self.active_input = nc + elif c == curses.KEY_DOWN: + ilen = len(self.inputs) + nc = min(self.active_input+1,ilen-1) + while isinstance(self.inputs[nc], Header): + nc+=1 + if nc >= ilen: break + if not isinstance(self.inputs[nc], Header): + self.active_input = nc + else: + self.inputs[self.active_input].handle_read(c) + + + def add_header(self, header, space_above=False, space_below=False): + self.inputs.append(Header(self.parent, header, space_above, space_below)) + + def add_text_input(self, name, msg, dflt_val): + self.inputs.append(TextInput(self.parent,self.move,self.width,msg,name,dflt_val,False)) + + def add_select_input(self, name, msg, opts, vals, selidx): + self.inputs.append(SelectInput(self.parent,msg,name,opts,vals,selidx)) + + def add_checked_input(self, name, message, checked): + self.inputs.append(CheckedInput(self.parent,message,name,checked)) + + def add_int_spin_input(self, name, message, value, min_val, max_val): + self.inputs.append(IntSpinInput(self.parent,message,name,self.move,value,min_val,max_val)) + + +class DownloadsPane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + + self.add_header("Folders") + self.add_text_input("download_location","Download To:",parent.core_config["download_location"]) + self.add_header("Allocation") + + if parent.core_config["compact_allocation"]: + alloc_idx = 1 + else: + alloc_idx = 0 + self.add_select_input("compact_allocation","Allocation:",["Use Full Allocation","Use Compact Allocation"],[False,True],alloc_idx) + self.add_header("Options",True) + self.add_checked_input("prioritize_first_last_pieces","Prioritize first and last pieces of torrent",parent.core_config["prioritize_first_last_pieces"]) + self.add_checked_input("add_paused","Add torrents in paused state",parent.core_config["add_paused"]) + + +class NetworkPane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + +class BandwidthPane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + self.add_header("Global Bandwidth Usage") + self.add_int_spin_input("max_connections_global","Maximum Connections:",parent.core_config["max_connections_global"],0,1000) + self.add_int_spin_input("max_upload_slots_global","Maximum Upload Slots:",parent.core_config["max_upload_slots_global"],0,1000) + #self.add_int_spin_input("max_download_speed","Maximum Download Speed (KiB/s):",-1,0,1000) + +class InterfacePane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + # does classic mode make sense in console? + #self.add_header("Classic Mode") + #self.add_checked_input("classic_mode","Enable",False) + + # add title bar control here + +class OtherPane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + self.add_header("GeoIP Database") + self.add_text_input("geoip_db_location","Location:",parent.core_config["geoip_db_location"]) + +class DaemonPane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + self.add_header("Port") + self.add_int_spin_input("daemon_port","Daemon Port:",parent.core_config["daemon_port"],0,1000) + self.add_header("Connections",True) + self.add_checked_input("allow_remote","Allow remote connections",parent.core_config["allow_remote"]) + self.add_header("Other",True) + self.add_checked_input("new_release_check","Periodically check the website for new releases",parent.core_config["new_release_check"]) + +class QueuePane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + +class ProxyPane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) + +class CachePane(BasePane): + def __init__(self, offset, parent, width): + BasePane.__init__(self,offset,parent,width) diff --git a/deluge/ui/console/modes/preferences.py b/deluge/ui/console/modes/preferences.py index 32b3c4c4f..b59b49932 100644 --- a/deluge/ui/console/modes/preferences.py +++ b/deluge/ui/console/modes/preferences.py @@ -35,9 +35,12 @@ # import deluge.component as component +from deluge.ui.client import client from basemode import BaseMode from input_popup import SelectInput +from preference_panes import DownloadsPane,NetworkPane,BandwidthPane,InterfacePane +from preference_panes import OtherPane,DaemonPane,QueuePane,ProxyPane,CachePane from collections import deque @@ -56,7 +59,7 @@ class ZONE: ACTIONS = 2 class Preferences(BaseMode): - def __init__(self, parent_mode, stdscr, encoding=None): + def __init__(self, parent_mode, core_config, stdscr, encoding=None): self.parent_mode = parent_mode self.categories = [_("Downloads"), _("Network"), _("Bandwidth"), _("Interface"), _("Other"), _("Daemon"), _("Queue"), _("Proxy"), @@ -66,13 +69,30 @@ class Preferences(BaseMode): self.messages = deque() self.action_input = None + self.core_config = core_config + self.active_zone = ZONE.CATEGORIES # how wide is the left 'pane' with categories self.div_off = 15 - BaseMode.__init__(self, stdscr, encoding) - self.action_input = SelectInput(self,None,None,["Cancel","Apply","OK"],0) + BaseMode.__init__(self, stdscr, encoding, False) + + # create the panes + self.prefs_width = self.cols-self.div_off-1 + self.panes = [ + DownloadsPane(self.div_off+2, self, self.prefs_width), + NetworkPane(self.div_off+2, self, self.prefs_width), + BandwidthPane(self.div_off+2, self, self.prefs_width), + InterfacePane(self.div_off+2, self, self.prefs_width), + OtherPane(self.div_off+2, self, self.prefs_width), + DaemonPane(self.div_off+2, self, self.prefs_width), + QueuePane(self.div_off+2, self, self.prefs_width), + ProxyPane(self.div_off+2, self, self.prefs_width), + CachePane(self.div_off+2, self, self.prefs_width) + ] + + self.action_input = SelectInput(self,None,None,["Cancel","Apply","OK"],[0,1,2],0) self.refresh() def __draw_catetories(self): @@ -85,11 +105,13 @@ class Preferences(BaseMode): self.add_string(i+1,"- %s"%category) self.stdscr.vline(1,self.div_off,'|',self.rows-2) + def __draw_preferences(self): + self.panes[self.cur_cat].render(self,self.stdscr, self.prefs_width, self.active_zone == ZONE.PREFRENCES) + def __draw_actions(self): - if self.action_input: - selected = self.active_zone == ZONE.ACTIONS - self.stdscr.hline(self.rows-3,self.div_off+1,"_",self.cols) - self.action_input.render(self.stdscr,self.rows-2,self.cols,selected,self.cols-22) + selected = self.active_zone == ZONE.ACTIONS + self.stdscr.hline(self.rows-3,self.div_off+1,"_",self.cols) + self.action_input.render(self.stdscr,self.rows-2,self.cols,selected,self.cols-22) def refresh(self): if self.popup == None and self.messages: @@ -103,6 +125,9 @@ class Preferences(BaseMode): self.__draw_catetories() self.__draw_actions() + + # do this last since it moves the cursor + self.__draw_preferences() self.stdscr.noutrefresh() @@ -119,7 +144,32 @@ class Preferences(BaseMode): self.cur_cat = min(len(self.categories)-1,self.cur_cat+1) def __prefs_read(self, c): - pass + self.panes[self.cur_cat].handle_read(c) + + def __apply_prefs(self): + new_core_config = {} + for pane in self.panes: + pane.add_config_values(new_core_config) + # Apply Core Prefs + if client.connected(): + # Only do this if we're connected to a daemon + config_to_set = {} + for key in new_core_config.keys(): + # The values do not match so this needs to be updated + if self.core_config[key] != new_core_config[key]: + config_to_set[key] = new_core_config[key] + + if config_to_set: + # Set each changed config value in the core + client.core.set_config(config_to_set) + client.force_call(True) + # Update the configuration + self.core_config.update(config_to_set) + + def __update_preferences(self,core_config): + self.core_config = core_config + for pane in self.panes: + pane.update_values(core_config) def __actions_read(self, c): self.action_input.handle_read(c) @@ -128,10 +178,10 @@ class Preferences(BaseMode): if self.action_input.selidx == 0: # cancel self.back_to_parent() elif self.action_input.selidx == 1: # apply - # TODO: Actually apply - pass + self.__apply_prefs() + client.core.get_config().addCallback(self.__update_preferences) elif self.action_input.selidx == 2: # OK - # TODO: Actually apply + self.__apply_prefs() self.back_to_parent() @@ -160,7 +210,7 @@ class Preferences(BaseMode): reactor.stop() return - elif c == 9: + if c == 9: self.active_zone += 1 if self.active_zone > ZONE.ACTIONS: self.active_zone = ZONE.CATEGORIES From 4a071ecba17110db822898185aebc7ef41a151a4 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 14 Feb 2011 12:38:18 +0100 Subject: [PATCH 39/55] add an eventview --- deluge/ui/console/modes/alltorrents.py | 10 +++ deluge/ui/console/modes/eventview.py | 105 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 deluge/ui/console/modes/eventview.py diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 049103666..2e4056ed3 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -49,6 +49,7 @@ from input_popup import InputPopup from torrentdetail import TorrentDetail from preferences import Preferences from torrent_actions import torrent_actions_popup +from eventview import EventView import format_utils @@ -336,6 +337,12 @@ class AllTorrents(BaseMode): prefs = Preferences(self,core_config,self.stdscr,self.encoding) component.get("ConsoleUI").set_mode(prefs) + def __show_events(self): + component.stop(["AllTorrentsStateUpdater"]) + self.stdscr.clear() + ev = EventView(self,self.stdscr,self.encoding) + component.get("ConsoleUI").set_mode(ev) + def _torrent_filter(self, idx, data): if data==FILTER.ALL: self.updater.status_dict = {} @@ -614,5 +621,8 @@ class AllTorrents(BaseMode): elif chr(c) == 'p': client.core.get_config().addCallback(self.show_preferences) return + elif chr(c) == 'e': + self.__show_events() + return self.refresh(effected_lines) diff --git a/deluge/ui/console/modes/eventview.py b/deluge/ui/console/modes/eventview.py new file mode 100644 index 000000000..31fb606a0 --- /dev/null +++ b/deluge/ui/console/modes/eventview.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# +# eventview.py +# +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +import deluge.component as component +from basemode import BaseMode +try: + import curses +except ImportError: + pass + +import logging +log = logging.getLogger(__name__) + +class EventView(BaseMode): + def __init__(self, parent_mode, stdscr, encoding=None): + self.parent_mode = parent_mode + BaseMode.__init__(self, stdscr, encoding) + + def refresh(self): + "This method just shows each line of the event log" + events = component.get("ConsoleUI").events + + self.add_string(0,self.statusbars.topbar) + hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) + self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) + + if events: + for i,event in enumerate(events): + self.add_string(i+1,event) + else: + self.add_string(1,"{!white,black,bold!}No events to show yet") + + self.stdscr.noutrefresh() + curses.doupdate() + + def back_to_overview(self): + self.stdscr.clear() + component.get("ConsoleUI").set_mode(self.parent_mode) + self.parent_mode.resume() + + def _doRead(self): + c = self.stdscr.getch() + + if c > 31 and c < 256: + if chr(c) == 'Q': + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + elif chr(c) == 'q': + self.back_to_overview() + return + + if c == 27: + self.back_to_overview() + return + + # TODO: Scroll event list + if c == curses.KEY_UP: + pass + elif c == curses.KEY_PPAGE: + pass + elif c == curses.KEY_DOWN: + pass + elif c == curses.KEY_NPAGE: + pass + + #self.refresh() From 8a9e732f9536688afd22ed44416555cf7507cf39 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 15 Feb 2011 18:55:27 +0100 Subject: [PATCH 40/55] lots of new preference work --- deluge/ui/console/modes/alltorrents.py | 22 ++- deluge/ui/console/modes/input_popup.py | 209 +++++++++++++++++++- deluge/ui/console/modes/preference_panes.py | 204 ++++++++++++++++--- deluge/ui/console/modes/preferences.py | 7 +- 4 files changed, 403 insertions(+), 39 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 2e4056ed3..4c606e009 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -331,11 +331,21 @@ class AllTorrents(BaseMode): td = TorrentDetail(self,tid,self.stdscr,self.encoding) component.get("ConsoleUI").set_mode(td) - def show_preferences(self, core_config): - component.stop(["AllTorrentsStateUpdater"]) - self.stdscr.clear() - prefs = Preferences(self,core_config,self.stdscr,self.encoding) - component.get("ConsoleUI").set_mode(prefs) + def show_preferences(self): + def _on_get_config(config): + client.core.get_listen_port().addCallback(_on_get_listen_port,config) + + def _on_get_listen_port(port,config): + client.core.get_cache_status().addCallback(_on_get_cache_status,port,config) + + def _on_get_cache_status(status,port,config): + component.stop(["AllTorrentsStateUpdater"]) + self.stdscr.clear() + prefs = Preferences(self,config,port,status,self.stdscr,self.encoding) + component.get("ConsoleUI").set_mode(prefs) + + client.core.get_config().addCallback(_on_get_config) + def __show_events(self): component.stop(["AllTorrentsStateUpdater"]) @@ -619,7 +629,7 @@ class AllTorrents(BaseMode): for l in HELP_LINES: self.popup.add_line(l) elif chr(c) == 'p': - client.core.get_config().addCallback(self.show_preferences) + self.show_preferences() return elif chr(c) == 'e': self.__show_events() diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py index 9c1dc4547..b59c914cd 100644 --- a/deluge/ui/console/modes/input_popup.py +++ b/deluge/ui/console/modes/input_popup.py @@ -49,6 +49,7 @@ from popup import Popup log = logging.getLogger(__name__) class InputField: + depend = None # render the input. return number of rows taken up def render(self,screen,row,width,selected,col=1): return 0 @@ -61,6 +62,20 @@ class InputField: def set_value(self, value): pass + def set_depend(self,i,inverse=False): + if not isinstance(i,CheckedInput): + raise Exception("Can only depend on CheckedInputs") + self.depend = i + self.inverse = inverse + + def depend_skip(self): + if not self.depend: + return False + if self.inverse: + return self.depend.checked + else: + return not self.depend.checked + class CheckedInput(InputField): def __init__(self, parent, message, name, checked=False): self.parent = parent @@ -92,6 +107,71 @@ class CheckedInput(InputField): def set_value(self, c): self.checked = c + +class CheckedPlusInput(InputField): + def __init__(self, parent, message, name, child,checked=False): + self.parent = parent + self.chkd_inact = "[X] %s"%message + self.unchkd_inact = "[ ] %s"%message + self.chkd_act = "[{!black,white,bold!}X{!white,black!}] %s"%message + self.unchkd_act = "[{!black,white,bold!} {!white,black!}] %s"%message + self.name = name + self.checked = checked + self.msglen = len(self.chkd_inact)+1 + self.child = child + self.child_active = False + + def render(self, screen, row, width, active, col=1): + isact = active and not self.child_active + if self.checked and isact: + self.parent.add_string(row,self.chkd_act,screen,col,False,True) + elif self.checked: + self.parent.add_string(row,self.chkd_inact,screen,col,False,True) + elif isact: + self.parent.add_string(row,self.unchkd_act,screen,col,False,True) + else: + self.parent.add_string(row,self.unchkd_inact,screen,col,False,True) + + if active and self.checked and self.child_active: + self.parent.add_string(row+1,"(esc to leave)",screen,col,False,True) + elif active and self.checked: + self.parent.add_string(row+1,"(right arrow to edit)",screen,col,False,True) + rows = 2 + # show child + if self.checked: + if isinstance(self.child,(TextInput,IntSpinInput,FloatSpinInput)): + crows = self.child.render(screen,row,width-self.msglen,self.child_active and active,col+self.msglen,self.msglen) + else: + crows = self.child.render(screen,row,width-self.msglen,self.child_active and active,col+self.msglen) + rows = max(rows,crows) + else: + self.parent.add_string(row,"(enable to view/edit value)",screen,col+self.msglen,False,True) + return rows + + def handle_read(self, c): + if self.child_active: + if c == 27: # leave child on esc + self.child_active = False + return + # pass keys through to child + self.child.handle_read(c) + else: + if c == 32: + self.checked = not self.checked + if c == curses.KEY_RIGHT: + self.child_active = True + + def get_value(self): + return self.checked + + def set_value(self, c): + self.checked = c + + def get_child(self): + return self.child + + + class IntSpinInput(InputField): def __init__(self, parent, message, name, move_func, value, min_val, max_val): self.parent = parent @@ -106,7 +186,7 @@ class IntSpinInput(InputField): self.min_val = min_val self.max_val = max_val - def render(self, screen, row, width, active, col=1): + def render(self, screen, row, width, active, col=1, cursor_offset=0): if not active and not self.valstr: self.value = self.initvalue self.valstr = "%d"%self.value @@ -119,7 +199,7 @@ class IntSpinInput(InputField): self.parent.add_string(row,"%s [ %d ]"%(self.message,self.value),screen,col,False,True) if active: - self.move_func(row,self.cursor+self.cursoff) + self.move_func(row,self.cursor+self.cursoff+cursor_offset) return 1 @@ -166,6 +246,112 @@ class IntSpinInput(InputField): self.valstr = "%d"%self.value self.cursor = len(self.valstr) + +class FloatSpinInput(InputField): + def __init__(self, parent, message, name, move_func, value, inc_amt, precision, min_val, max_val): + self.parent = parent + self.message = message + self.name = name + self.precision = precision + self.inc_amt = inc_amt + self.value = round(float(value),self.precision) + self.initvalue = self.value + self.fmt = "%%.%df"%precision + self.valstr = self.fmt%self.value + self.cursor = len(self.valstr) + self.cursoff = len(self.message)+4 # + 4 for the " [ " in the rendered string + self.move_func = move_func + self.min_val = min_val + self.max_val = max_val + self.need_update = False + + def render(self, screen, row, width, active, col=1, cursor_offset=0): + if not active and not self.valstr: + self.value = self.initvalue + self.valstr = self.fmt%self.value + self.cursor = len(self.valstr) + if not active and self.need_update: + self.value = round(float(self.valstr),self.precision) + self.valstr = self.fmt%self.value + self.cursor = len(self.valstr) + if not self.valstr: + self.parent.add_string(row,"%s [ ]"%self.message,screen,col,False,True) + elif active: + self.parent.add_string(row,"%s [ {!black,white,bold!}%s{!white,black!} ]"%(self.message,self.valstr),screen,col,False,True) + else: + self.parent.add_string(row,"%s [ %s ]"%(self.message,self.valstr),screen,col,False,True) + if active: + self.move_func(row,self.cursor+self.cursoff+cursor_offset) + + return 1 + + def handle_read(self, c): + if c == curses.KEY_PPAGE: + self.value+=self.inc_amt + self.valstr = self.fmt%self.value + self.cursor = len(self.valstr) + elif c == curses.KEY_NPAGE: + self.value-=self.inc_amt + self.valstr = self.fmt%self.value + self.cursor = len(self.valstr) + elif c == curses.KEY_LEFT: + self.cursor = max(0,self.cursor-1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.valstr),self.cursor+1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.value) + elif c == curses.KEY_BACKSPACE or c == 127: + if self.valstr and self.cursor > 0: + self.valstr = self.valstr[:self.cursor - 1] + self.valstr[self.cursor:] + self.cursor-=1 + self.need_update = True + elif c == curses.KEY_DC: + if self.valstr and self.cursor < len(self.valstr): + self.valstr = self.valstr[:self.cursor] + self.valstr[self.cursor+1:] + self.need_update = True + elif c == 45 and self.cursor == 0 and self.min_val < 0: + minus_place = self.valstr.find('-') + if minus_place >= 0: return + self.valstr = chr(c)+self.valstr + self.cursor += 1 + self.need_update = True + elif c == 46: + minus_place = self.valstr.find('-') + if self.cursor <= minus_place: return + point_place = self.valstr.find('.') + if point_place >= 0: return + if self.cursor == len(self.valstr): + self.valstr += chr(c) + else: + # Insert into string + self.valstr = self.valstr[:self.cursor] + chr(c) + self.valstr[self.cursor:] + self.need_update = True + # Move the cursor forward + self.cursor+=1 + elif (c > 47 and c < 58): + minus_place = self.valstr.find('-') + if self.cursor <= minus_place: return + if self.cursor == len(self.valstr): + self.valstr += chr(c) + else: + # Insert into string + self.valstr = self.valstr[:self.cursor] + chr(c) + self.valstr[self.cursor:] + self.need_update = True + # Move the cursor forward + self.cursor+=1 + + + def get_value(self): + return self.value + + def set_value(self, val): + self.value = round(float(val),self.precision) + self.valstr = self.fmt%self.value + self.cursor = len(self.valstr) + + class SelectInput(InputField): def __init__(self, parent, message, name, opts, vals, selidx): self.parent = parent @@ -225,23 +411,28 @@ class TextInput(InputField): self.opts = None self.opt_off = 0 - def render(self,screen,row,width,selected,col=1): + def render(self,screen,row,width,selected,col=1,cursor_offset=0): + if self.message: + self.parent.add_string(row,self.message,screen,col,False,True) + row += 1 if selected: if self.opts: - self.parent.add_string(row+2,self.opts[self.opt_off:],screen,col,False,True) + self.parent.add_string(row+1,self.opts[self.opt_off:],screen,col,False,True) if self.cursor > (width-3): - self.move_func(row+1,width-2) + self.move_func(row,width-2) else: - self.move_func(row+1,self.cursor+1) - self.parent.add_string(row,self.message,screen,col,False,True) + self.move_func(row,self.cursor+1+cursor_offset) slen = len(self.value)+3 if slen > width: vstr = self.value[(slen-width):] else: vstr = self.value.ljust(width-2) - self.parent.add_string(row+1,"{!black,white,bold!}%s"%vstr,screen,col,False,False) + self.parent.add_string(row,"{!black,white,bold!}%s"%vstr,screen,col,False,False) - return 3 + if self.message: + return 3 + else: + return 2 def get_value(self): return self.value diff --git a/deluge/ui/console/modes/preference_panes.py b/deluge/ui/console/modes/preference_panes.py index 572adb5a4..d30a4f978 100644 --- a/deluge/ui/console/modes/preference_panes.py +++ b/deluge/ui/console/modes/preference_panes.py @@ -33,7 +33,7 @@ # # -from deluge.ui.console.modes.input_popup import TextInput,SelectInput,CheckedInput,IntSpinInput +from deluge.ui.console.modes.input_popup import TextInput,SelectInput,CheckedInput,IntSpinInput,FloatSpinInput,CheckedPlusInput try: import curses @@ -44,12 +44,17 @@ import logging log = logging.getLogger(__name__) -class Header: +class NoInput: + def depend_skip(self): + return False + +class Header(NoInput): def __init__(self, parent, header, space_above, space_below): self.parent = parent self.header = "{!white,black,bold!}%s"%header self.space_above = space_above self.space_below = space_below + self.name = header def render(self, screen, row, width, active, offset): rows = 1 @@ -60,6 +65,24 @@ class Header: if self.space_below: rows += 1 return rows +class InfoField(NoInput): + def __init__(self,parent,label,value,name): + self.parent = parent + self.label = label + self.value = value + self.txt = "%s %s"%(label,value) + self.name = name + + def render(self, screen, row, width, active, offset): + self.parent.add_string(row,self.txt,screen,offset-1,False,True) + return 1 + + def set_value(self, v): + self.value = v + if type(v) == float: + self.txt = "%s %.2f"%(self.label,self.value) + else: + self.txt = "%s %s"%(self.label,self.value) class BasePane: def __init__(self, offset, parent, width): @@ -75,26 +98,45 @@ class BasePane: def add_config_values(self,conf_dict): for ipt in self.inputs: - if not isinstance(ipt,Header): - conf_dict[ipt.name] = ipt.get_value() + if not isinstance(ipt,NoInput): + # gross, have to special case in/out ports since they are tuples + if ipt.name in ("listen_ports_to","listen_ports_from", + "out_ports_from","out_ports_to"): + if ipt.name == "listen_ports_to": + conf_dict["listen_ports"] = (self.infrom.get_value(),self.into.get_value()) + if ipt.name == "out_ports_to": + conf_dict["outgoing_ports"] = (self.outfrom.get_value(),self.outto.get_value()) + else: + conf_dict[ipt.name] = ipt.get_value() + if hasattr(ipt,"get_child"): + c = ipt.get_child() + conf_dict[c.name] = c.get_value() def update_values(self, conf_dict): for ipt in self.inputs: - if not isinstance(ipt,Header): + if not isinstance(ipt,NoInput): try: ipt.set_value(conf_dict[ipt.name]) except KeyError: # just ignore if it's not in dict pass + if hasattr(ipt,"get_child"): + try: + c = ipt.get_child() + c.set_value(conf_dict[c.name]) + except KeyError: # just ignore if it's not in dict + pass def render(self, mode, screen, width, active): self._cursor_row = -1 if self.active_input < 0: for i,ipt in enumerate(self.inputs): - if not isinstance(ipt,Header): + if not isinstance(ipt,NoInput): self.active_input = i - break + break crow = 1 for i,ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue act = active and i==self.active_input crow += ipt.render(screen,crow,width, act, self.offset) @@ -104,6 +146,8 @@ class BasePane: else: curses.curs_set(0) + return crow + # just handles setting the active input def handle_read(self,c): if not self.inputs: # no inputs added yet @@ -111,18 +155,20 @@ class BasePane: if c == curses.KEY_UP: nc = max(0,self.active_input-1) - while isinstance(self.inputs[nc], Header): + while isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): nc-=1 if nc <= 0: break - if not isinstance(self.inputs[nc], Header): + if not isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): self.active_input = nc elif c == curses.KEY_DOWN: ilen = len(self.inputs) nc = min(self.active_input+1,ilen-1) - while isinstance(self.inputs[nc], Header): + while isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): nc+=1 - if nc >= ilen: break - if not isinstance(self.inputs[nc], Header): + if nc >= ilen: + nc-=1 + break + if not isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): self.active_input = nc else: self.inputs[self.active_input].handle_read(c) @@ -131,6 +177,9 @@ class BasePane: def add_header(self, header, space_above=False, space_below=False): self.inputs.append(Header(self.parent, header, space_above, space_below)) + def add_info_field(self, label, value, name): + self.inputs.append(InfoField(self.parent, label, value, name)) + def add_text_input(self, name, msg, dflt_val): self.inputs.append(TextInput(self.parent,self.move,self.width,msg,name,dflt_val,False)) @@ -140,9 +189,15 @@ class BasePane: def add_checked_input(self, name, message, checked): self.inputs.append(CheckedInput(self.parent,message,name,checked)) + def add_checkedplus_input(self, name, message, child, checked): + self.inputs.append(CheckedPlusInput(self.parent,message,name,child,checked)) + def add_int_spin_input(self, name, message, value, min_val, max_val): self.inputs.append(IntSpinInput(self.parent,message,name,self.move,value,min_val,max_val)) + def add_float_spin_input(self, name, message, value, inc_amt, precision, min_val, max_val): + self.inputs.append(FloatSpinInput(self.parent,message,name,self.move,value,inc_amt,precision,min_val,max_val)) + class DownloadsPane(BasePane): def __init__(self, offset, parent, width): @@ -150,13 +205,21 @@ class DownloadsPane(BasePane): self.add_header("Folders") self.add_text_input("download_location","Download To:",parent.core_config["download_location"]) - self.add_header("Allocation") + cmptxt = TextInput(self.parent,self.move,self.width,None,"move_completed_path",parent.core_config["move_completed_path"],False) + self.add_checkedplus_input("move_completed","Move completed to:",cmptxt,parent.core_config["move_completed"]) + autotxt = TextInput(self.parent,self.move,self.width,None,"autoadd_location",parent.core_config["autoadd_location"],False) + self.add_checkedplus_input("autoadd_enable","Auto add .torrents from:",autotxt,parent.core_config["autoadd_enable"]) + copytxt = TextInput(self.parent,self.move,self.width,None,"torrentfiles_location",parent.core_config["torrentfiles_location"],False) + self.add_checkedplus_input("copy_torrent_file","Copy of .torrent files to:",copytxt,parent.core_config["copy_torrent_file"]) + self.add_checked_input("del_copy_torrent_file","Delete copy of torrent file on remove",parent.core_config["del_copy_torrent_file"]) + + self.add_header("Allocation",True) if parent.core_config["compact_allocation"]: alloc_idx = 1 else: alloc_idx = 0 - self.add_select_input("compact_allocation","Allocation:",["Use Full Allocation","Use Compact Allocation"],[False,True],alloc_idx) + self.add_select_input("compact_allocation",None,["Use Full Allocation","Use Compact Allocation"],[False,True],alloc_idx) self.add_header("Options",True) self.add_checked_input("prioritize_first_last_pieces","Prioritize first and last pieces of torrent",parent.core_config["prioritize_first_last_pieces"]) self.add_checked_input("add_paused","Add torrents in paused state",parent.core_config["add_paused"]) @@ -165,35 +228,91 @@ class DownloadsPane(BasePane): class NetworkPane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) + self.add_header("Incomming Ports") + inrand = CheckedInput(parent,"Use Random Ports Active Port: %d"%parent.active_port,"random_port",parent.core_config["random_port"]) + self.inputs.append(inrand) + listen_ports = parent.core_config["listen_ports"] + self.infrom = IntSpinInput(self.parent," From:","listen_ports_from",self.move,listen_ports[0],0,65535) + self.infrom.set_depend(inrand,True) + self.into = IntSpinInput(self.parent," To: ","listen_ports_to",self.move,listen_ports[1],0,65535) + self.into.set_depend(inrand,True) + self.inputs.append(self.infrom) + self.inputs.append(self.into) + + + self.add_header("Outgoing Ports",True) + outrand = CheckedInput(parent,"Use Random Ports","random_outgoing_ports",parent.core_config["random_outgoing_ports"]) + self.inputs.append(outrand) + out_ports = parent.core_config["outgoing_ports"] + self.outfrom = IntSpinInput(self.parent," From:","out_ports_from",self.move,out_ports[0],0,65535) + self.outfrom.set_depend(outrand,True) + self.outto = IntSpinInput(self.parent," To: ","out_ports_to",self.move,out_ports[1],0,65535) + self.outto.set_depend(outrand,True) + self.inputs.append(self.outfrom) + self.inputs.append(self.outto) + + + self.add_header("Interface",True) + self.add_text_input("listen_interface","IP address of the interface to listen on (leave empty for default):",parent.core_config["listen_interface"]) + + self.add_header("TOS",True) + self.add_text_input("peer_tos","Peer TOS Byte:",parent.core_config["peer_tos"]) + + self.add_header("Network Extras") + self.add_checked_input("upnp","UPnP",parent.core_config["upnp"]) + self.add_checked_input("natpmp","NAT-PMP",parent.core_config["natpmp"]) + self.add_checked_input("utpex","Peer Exchange",parent.core_config["utpex"]) + self.add_checked_input("lsd","LSD",parent.core_config["lsd"]) + self.add_checked_input("dht","DHT",parent.core_config["dht"]) + + self.add_header("Encryption",True) + self.add_select_input("enc_in_policy","Inbound:",["Forced","Enabled","Disabled"],[0,1,2],parent.core_config["enc_in_policy"]) + self.add_select_input("enc_out_policy","Outbound:",["Forced","Enabled","Disabled"],[0,1,2],parent.core_config["enc_out_policy"]) + self.add_select_input("enc_level","Level:",["Handshake","Full Stream","Either"],[0,1,2],parent.core_config["enc_level"]) + self.add_checked_input("enc_prefer_rc4","Encrypt Entire Stream",parent.core_config["enc_prefer_rc4"]) + class BandwidthPane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) self.add_header("Global Bandwidth Usage") - self.add_int_spin_input("max_connections_global","Maximum Connections:",parent.core_config["max_connections_global"],0,1000) - self.add_int_spin_input("max_upload_slots_global","Maximum Upload Slots:",parent.core_config["max_upload_slots_global"],0,1000) - #self.add_int_spin_input("max_download_speed","Maximum Download Speed (KiB/s):",-1,0,1000) + self.add_int_spin_input("max_connections_global","Maximum Connections:",parent.core_config["max_connections_global"],-1,9000) + self.add_int_spin_input("max_upload_slots_global","Maximum Upload Slots:",parent.core_config["max_upload_slots_global"],-1,9000) + self.add_float_spin_input("max_download_speed","Maximum Download Speed (KiB/s):",parent.core_config["max_download_speed"],1.0,1,-1.0,60000.0) + self.add_float_spin_input("max_upload_speed","Maximum Upload Speed (KiB/s):",parent.core_config["max_upload_speed"],1.0,1,-1.0,60000.0) + self.add_int_spin_input("max_half_open_connections","Maximum Half-Open Connections:",parent.core_config["max_half_open_connections"],-1,9999) + self.add_int_spin_input("max_connections_per_second","Maximum Connection Attempts per Second:",parent.core_config["max_connections_per_second"],-1,9999) + self.add_checked_input("ignore_limits_on_local_network","Ignore limits on local network",parent.core_config["ignore_limits_on_local_network"]) + self.add_checked_input("rate_limit_ip_overhead","Rate Limit IP Overhead",parent.core_config["rate_limit_ip_overhead"]) + self.add_header("Per Torrent Bandwidth Usage",True) + self.add_int_spin_input("max_connections_per_torrent","Maximum Connections:",parent.core_config["max_connections_per_torrent"],-1,9000) + self.add_int_spin_input("max_upload_slots_per_torrent","Maximum Upload Slots:",parent.core_config["max_upload_slots_per_torrent"],-1,9000) + self.add_float_spin_input("max_download_speed_per_torrent","Maximum Download Speed (KiB/s):",parent.core_config["max_download_speed_per_torrent"],1.0,1,-1.0,60000.0) + self.add_float_spin_input("max_upload_speed_per_torrent","Maximum Upload Speed (KiB/s):",parent.core_config["max_upload_speed_per_torrent"],1.0,1,-1.0,60000.0) class InterfacePane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) - # does classic mode make sense in console? - #self.add_header("Classic Mode") - #self.add_checked_input("classic_mode","Enable",False) - + self.add_header("Interface Settings Comming Soon") # add title bar control here + class OtherPane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) - self.add_header("GeoIP Database") + self.add_header("System Information") + self.add_info_field(" Help us improve Deluge by sending us your","","") + self.add_info_field(" Python version, PyGTK version, OS and processor","","") + self.add_info_field(" types. Absolutely no other information is sent.","","") + self.add_checked_input("send_info","Yes, please send anonymous statistics.",parent.core_config["send_info"]) + self.add_header("GeoIP Database",True) self.add_text_input("geoip_db_location","Location:",parent.core_config["geoip_db_location"]) class DaemonPane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) self.add_header("Port") - self.add_int_spin_input("daemon_port","Daemon Port:",parent.core_config["daemon_port"],0,1000) + self.add_int_spin_input("daemon_port","Daemon Port:",parent.core_config["daemon_port"],0,65535) self.add_header("Connections",True) self.add_checked_input("allow_remote","Allow remote connections",parent.core_config["allow_remote"]) self.add_header("Other",True) @@ -202,11 +321,50 @@ class DaemonPane(BasePane): class QueuePane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) + self.add_header("General") + self.add_checked_input("queue_new_to_top","Queue new torrents to top",parent.core_config["queue_new_to_top"]) + self.add_header("Active Torrents",True) + self.add_int_spin_input("max_active_limit","Total active:",parent.core_config["max_active_limit"],-1,9999) + self.add_int_spin_input("max_active_downloading","Total active downloading:",parent.core_config["max_active_downloading"],-1,9999) + self.add_int_spin_input("max_active_seeding","Total active seeding:",parent.core_config["max_active_seeding"],-1,9999) + self.add_checked_input("dont_count_slow_torrents","Do not count slow torrents",parent.core_config["dont_count_slow_torrents"]) + self.add_header("Seeding",True) + self.add_float_spin_input("share_ratio_limit","Share Ratio Limit:",parent.core_config["share_ratio_limit"],1.0,2,-1.0,100.0) + self.add_float_spin_input("seed_time_ratio_limit","Share Time Ratio:",parent.core_config["seed_time_ratio_limit"],1.0,2,-1.0,100.0) + self.add_int_spin_input("seed_time_limit","Seed time (m):",parent.core_config["seed_time_limit"],-1,10000) + seedratio = FloatSpinInput(self.parent,"","stop_seed_ratio",self.move,parent.core_config["stop_seed_ratio"],0.1,2,0.5,100.0) + self.add_checkedplus_input("stop_seed_at_ratio","Stop seeding when share ratio reaches:",seedratio,parent.core_config["stop_seed_at_ratio"]) + self.add_checked_input("remove_seed_at_ratio","Remove torrent when share ratio reached",parent.core_config["remove_seed_at_ratio"]) class ProxyPane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) + self.add_header("Proxy Settings Comming Soon") class CachePane(BasePane): def __init__(self, offset, parent, width): BasePane.__init__(self,offset,parent,width) + self.add_header("Settings") + self.add_int_spin_input("cache_size","Cache Size (16 KiB blocks):",parent.core_config["cache_size"],0,99999) + self.add_int_spin_input("cache_expiry","Cache Expiry (seconds):",parent.core_config["cache_expiry"],1,32000) + self.add_header("Status (press 'r' to refresh status)",True) + self.add_header(" Write") + self.add_info_field(" Blocks Written:",self.parent.status["blocks_written"],"blocks_written") + self.add_info_field(" Writes:",self.parent.status["writes"],"writes") + self.add_info_field(" Write Cache Hit Ratio:","%.2f"%self.parent.status["write_hit_ratio"],"write_hit_ratio") + self.add_header(" Read") + self.add_info_field(" Blocks Read:",self.parent.status["blocks_read"],"blocks_read") + self.add_info_field(" Blocks Read hit:",self.parent.status["blocks_read_hit"],"blocks_read_hit") + self.add_info_field(" Reads:",self.parent.status["reads"],"reads") + self.add_info_field(" Read Cache Hit Ratio:","%.2f"%self.parent.status["read_hit_ratio"],"read_hit_ratio") + self.add_header(" Size") + self.add_info_field(" Cache Size:",self.parent.status["cache_size"],"cache_size") + self.add_info_field(" Read Cache Size:",self.parent.status["read_cache_size"],"read_cache_size") + + def update_cache_status(self, status): + for ipt in self.inputs: + if isinstance(ipt,InfoField): + try: + ipt.set_value(status[ipt.name]) + except KeyError: + pass diff --git a/deluge/ui/console/modes/preferences.py b/deluge/ui/console/modes/preferences.py index b59b49932..929101332 100644 --- a/deluge/ui/console/modes/preferences.py +++ b/deluge/ui/console/modes/preferences.py @@ -59,7 +59,7 @@ class ZONE: ACTIONS = 2 class Preferences(BaseMode): - def __init__(self, parent_mode, core_config, stdscr, encoding=None): + def __init__(self, parent_mode, core_config, active_port, status, stdscr, encoding=None): self.parent_mode = parent_mode self.categories = [_("Downloads"), _("Network"), _("Bandwidth"), _("Interface"), _("Other"), _("Daemon"), _("Queue"), _("Proxy"), @@ -70,6 +70,8 @@ class Preferences(BaseMode): self.action_input = None self.core_config = core_config + self.active_port = active_port + self.status = status self.active_zone = ZONE.CATEGORIES @@ -220,6 +222,9 @@ class Preferences(BaseMode): if self.active_zone < ZONE.CATEGORIES: self.active_zone = ZONE.ACTIONS + elif c == 114 and isinstance(self.panes[self.cur_cat],CachePane): + client.core.get_cache_status().addCallback(self.panes[self.cur_cat].update_cache_status) + else: if self.active_zone == ZONE.CATEGORIES: self.__category_read(c) From c015c3a57d5ef6bb53c55112f48aaa3b33b2fed3 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 15 Feb 2011 19:10:51 +0100 Subject: [PATCH 41/55] update help a bit --- deluge/ui/console/modes/alltorrents.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 4c606e009..72403305e 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -80,9 +80,11 @@ All popup windows can be closed/canceled by hitting the Esc key The actions you can perform and the keys to perform them are as follows: +'h' - Show this help + 'a' - Add a torrent -'h' - Show this help +'p' - View/Set preferences 'f' - Show only torrents in a certain state (Will open a popup where you can select the state you want to see) @@ -96,7 +98,7 @@ The actions you can perform and the keys to perform them are as follows: and last marked torrent 'c' - Un-mark all torrents -Right Arrow - Show torrent details. This includes more detailed information +Right Arrow - Torrent Detail Mode. This includes more detailed information about the currently selected torrent, as well as a view of the files in the torrent and the ability to set file priorities. From 7a4006439b5c4b7cc4a5c568945448a70915cc78 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 15 Feb 2011 19:10:57 +0100 Subject: [PATCH 42/55] add preferences help --- deluge/ui/console/modes/preferences.py | 52 +++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/preferences.py b/deluge/ui/console/modes/preferences.py index 929101332..c96324db6 100644 --- a/deluge/ui/console/modes/preferences.py +++ b/deluge/ui/console/modes/preferences.py @@ -37,7 +37,7 @@ import deluge.component as component from deluge.ui.client import client from basemode import BaseMode -from input_popup import SelectInput +from input_popup import Popup,SelectInput from preference_panes import DownloadsPane,NetworkPane,BandwidthPane,InterfacePane from preference_panes import OtherPane,DaemonPane,QueuePane,ProxyPane,CachePane @@ -53,6 +53,52 @@ except ImportError: import logging log = logging.getLogger(__name__) + +# Big help string that gets displayed when the user hits 'h' +HELP_STR = \ +"""This screen lets you view and configure various options +in deluge. + +There are three main sections to this screen. Only one +section is active at a time. You can switch the active +section by hitting TAB (or Shift-TAB to go back one) + +The section on the left displays the various categories +that the settings fall in. You can navigate the list +using the up/down arrows + +The section on the right shows the settings for the +selected category. When this section is active +you can navigate the various settings with the up/down +arrows. Special keys for each input type are described +below. + +The final section is at the bottom right, the: +[Cancel] [Apply] [OK] buttons. When this section +is active, simply select the option you want using +the arrow keys and press Enter to confim. + + +Special keys for various input types are as follows: +- For text inputs you can simply type in the value. + +- For numeric inputs (indicated by the value being + in []s), you can type a value, or use PageUp and + PageDown to increment/decrement the value. + +- For checkbox inputs use the spacebar to toggle + +- For checkbox plus something else inputs (the + something else being only visible when you + check the box) you can toggle the check with + space, use the right arrow to edit the other + value, and escape to get back to the check box. + + +""" +HELP_LINES = HELP_STR.split('\n') + + class ZONE: CATEGORIES = 0 PREFRENCES = 1 @@ -211,6 +257,10 @@ class Preferences(BaseMode): else: reactor.stop() return + elif chr(c) == 'h': + self.popup = Popup(self,"Preferences Help") + for l in HELP_LINES: + self.popup.add_line(l) if c == 9: self.active_zone += 1 From 4ff0fb19ee4752f91249efb618b4a3cd0d611495 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 15 Feb 2011 19:36:17 +0100 Subject: [PATCH 43/55] left arrow goes back to overview from torrent details --- deluge/ui/console/modes/torrentdetail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index 6beaa7ed2..646e00b02 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -439,7 +439,7 @@ class TorrentDetail(BaseMode, component.Component): self.back_to_overview() return - if c == 27: + if c == 27 or c == curses.KEY_LEFT: self.back_to_overview() return From 962bfc3d2cee92a5a64cfc5fa79d2a8a9677742d Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 15 Feb 2011 20:14:14 +0100 Subject: [PATCH 44/55] support searching torrent names in alltorrent view --- deluge/ui/console/modes/alltorrents.py | 94 ++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index 72403305e..c4b831fbd 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -67,7 +67,8 @@ HELP_STR = \ """This screen shows an overview of the current torrents Deluge is managing. The currently selected torrent is indicated by having a white background. You can change the selected torrent using the up/down arrows or the -PgUp/Pg keys. +PgUp/Pg keys. Home and End keys go to the first and last torrent +respectively. Operations can be performed on multiple torrents by marking them and then hitting Enter. See below for the keys used to mark torrents. @@ -86,6 +87,10 @@ The actions you can perform and the keys to perform them are as follows: 'p' - View/Set preferences +'/' - Search torrent names. Enter to exectue search, ESC to cancel + +'n' - Next matching torrent for last search + 'f' - Show only torrents in a certain state (Will open a popup where you can select the state you want to see) @@ -149,6 +154,7 @@ class StateUpdater(component.Component): class AllTorrents(BaseMode): def __init__(self, stdscr, encoding=None): self.formatted_rows = None + self.torrent_names = None self.cursel = 1 self.curoff = 1 # TODO: this should really be 0 indexed self.column_string = "" @@ -160,6 +166,9 @@ class AllTorrents(BaseMode): self._go_top = False self._curr_filter = None + self.entering_search = False + self.search_string = None + self.cursor = 0 self.coreconfig = component.get("ConsoleUI").coreconfig @@ -227,10 +236,12 @@ class AllTorrents(BaseMode): def set_state(self, state, refresh): self.curstate = state # cache in case we change sort order + newnames = [] newrows = [] self._sorted_ids = self._sort_torrents(self.curstate) for torrent_id in self._sorted_ids: ts = self.curstate[torrent_id] + newnames.append(ts["name"]) newrows.append((format_utils.format_row([self._format_queue(ts["queue"]), ts["name"], "%s"%deluge.common.fsize(ts["total_wanted"]), @@ -243,6 +254,7 @@ class AllTorrents(BaseMode): ],self.column_widths),ts["state"])) self.numtorrents = len(state) self.formatted_rows = newrows + self.torrent_names = newnames if refresh: self.refresh() @@ -455,8 +467,12 @@ class AllTorrents(BaseMode): else: self.add_string(0,"%s {!filterstatus!}Current filter: %s"%(self.statusbars.topbar,self._curr_filter)) self.add_string(1,self.column_string) - hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) - self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) + + if self.entering_search: + self.add_string(self.rows - 1,"Search torrents: %s"%self.search_string) + else: + hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) + self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) # add all the torrents if self.formatted_rows == []: @@ -520,6 +536,12 @@ class AllTorrents(BaseMode): self.add_string(1, "Waiting for torrents from core...") #self.stdscr.redrawwin() + if self.entering_search: + curses.curs_set(2) + self.stdscr.move(self.rows-1,self.cursor+17) + else: + curses.curs_set(0) + self.stdscr.noutrefresh() if self.popup: @@ -536,6 +558,54 @@ class AllTorrents(BaseMode): self.marked.append(idx) self.last_mark = idx + def __do_search(self): + # search forward for the next torrent matching self.search_string + for i,n in enumerate(self.torrent_names[self.cursel:]): + if n.find(self.search_string) >= 0: + self.cursel += (i+1) + if ((self.curoff + self.rows - 5) < self.cursel): + self.curoff = self.cursel - self.rows + 5 + return + + def __update_search(self, c): + if c == curses.KEY_BACKSPACE or c == 127: + if self.search_string and self.cursor > 0: + self.search_string = self.search_string[:self.cursor - 1] + self.search_string[self.cursor:] + self.cursor-=1 + elif c == curses.KEY_DC: + if self.search_string and self.cursor < len(self.search_string): + self.search_string = self.search_string[:self.cursor] + self.search_string[self.cursor+1:] + elif c == curses.KEY_LEFT: + self.cursor = max(0,self.cursor-1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.search_string),self.cursor+1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.search_string) + elif c == 27: + self.search_string = None + self.entering_search = False + elif c == 10 or c == curses.KEY_ENTER: + self.entering_search = False + self.__do_search() + elif c > 31 and c < 256: + stroke = chr(c) + uchar = "" + while not uchar: + try: + uchar = stroke.decode(self.encoding) + except UnicodeDecodeError: + c = self.stdscr.getch() + stroke += chr(c) + if uchar: + if self.cursor == len(self.search_string): + self.search_string += uchar + else: + # Insert into string + self.search_string = self.search_string[:self.cursor] + uchar + self.search_string[self.cursor:] + # Move the cursor forward + self.cursor+=1 def _doRead(self): # Read the character @@ -563,6 +633,11 @@ class AllTorrents(BaseMode): if self.formatted_rows==None or self.popup: return + elif self.entering_search: + self.__update_search(c) + self.refresh([]) + return + #log.error("pressed key: %d\n",c) #if c == 27: # handle escape # log.error("CANCEL") @@ -580,6 +655,10 @@ class AllTorrents(BaseMode): effected_lines = [self.cursel-2,self.cursel-1] elif c == curses.KEY_NPAGE: self._scroll_down(int(self.rows/2)) + elif c == curses.KEY_HOME: + self._scroll_up(self.cursel) + elif c == curses.KEY_END: + self._scroll_down(self.numtorrents-self.cursel) elif c == curses.KEY_RIGHT: # We enter a new mode for the selected torrent here @@ -594,10 +673,15 @@ class AllTorrents(BaseMode): self.last_mark = self.cursel torrent_actions_popup(self,self._selected_torrent_ids(),details=True) return - else: if c > 31 and c < 256: - if chr(c) == 'j': + if chr(c) == '/': + self.search_string = "" + self.cursor = 0 + self.entering_search = True + elif chr(c) == 'n' and self.search_string: + self.__do_search() + elif chr(c) == 'j': if not self._scroll_up(1): effected_lines = [self.cursel-1,self.cursel] elif chr(c) == 'k': From ce2516ab2c667cffdd965807af285851ccf03728 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 15 Feb 2011 20:15:43 +0100 Subject: [PATCH 45/55] search field is black,white --- deluge/ui/console/modes/alltorrents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index c4b831fbd..e9d1899d2 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -469,7 +469,7 @@ class AllTorrents(BaseMode): self.add_string(1,self.column_string) if self.entering_search: - self.add_string(self.rows - 1,"Search torrents: %s"%self.search_string) + self.add_string(self.rows - 1,"{!black,white!}Search torrents: %s"%self.search_string) else: hstr = "%sPress [h] for help"%(" "*(self.cols - len(self.statusbars.bottombar) - 10)) self.add_string(self.rows - 1, "%s%s"%(self.statusbars.bottombar,hstr)) From 837322478b3de381b5026cbd4d7ccd7b8d8a77ad Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 17 Feb 2011 15:30:14 +0100 Subject: [PATCH 46/55] return deferred for proper command line behavior --- deluge/ui/console/commands/cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deluge/ui/console/commands/cache.py b/deluge/ui/console/commands/cache.py index eee7e049f..92c016d9f 100644 --- a/deluge/ui/console/commands/cache.py +++ b/deluge/ui/console/commands/cache.py @@ -47,4 +47,6 @@ class Command(BaseCommand): for key, value in status.items(): self.console.write("{!info!}%s: {!input!}%s" % (key, value)) - client.core.get_cache_status().addCallback(on_cache_status) + d = client.core.get_cache_status() + d.addCallback(on_cache_status) + return d From 1789e8d03cec2a395605153cafaeb4bcb5f5ff1c Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 17 Feb 2011 16:03:11 +0100 Subject: [PATCH 47/55] Minor changes for command line usage --- deluge/ui/console/commands/debug.py | 2 +- deluge/ui/console/commands/halt.py | 3 ++- deluge/ui/console/commands/quit.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deluge/ui/console/commands/debug.py b/deluge/ui/console/commands/debug.py index 118f9542b..40e87eda2 100644 --- a/deluge/ui/console/commands/debug.py +++ b/deluge/ui/console/commands/debug.py @@ -44,7 +44,7 @@ import deluge.component as component class Command(BaseCommand): """Enable and disable debugging""" - usage = 'debug [on|off]' + usage = 'Usage: debug [on|off]' def handle(self, state='', **options): if state == 'on': deluge.log.setLoggerLevel("debug") diff --git a/deluge/ui/console/commands/halt.py b/deluge/ui/console/commands/halt.py index 0a4de27a9..1c239dec4 100644 --- a/deluge/ui/console/commands/halt.py +++ b/deluge/ui/console/commands/halt.py @@ -39,7 +39,8 @@ from deluge.ui.client import client import deluge.component as component class Command(BaseCommand): - "Shutdown the deluge server." + "Shutdown the deluge server" + usage = "Usage: halt" def handle(self, **options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/quit.py b/deluge/ui/console/commands/quit.py index f0e2fae22..1c95f9461 100644 --- a/deluge/ui/console/commands/quit.py +++ b/deluge/ui/console/commands/quit.py @@ -40,6 +40,7 @@ from twisted.internet import reactor class Command(BaseCommand): """Exit from the client.""" aliases = ['exit'] + interactive_only = True def handle(self, *args, **options): if client.connected(): def on_disconnect(result): From 9a3316f95028bb5a1fe72d43b09ae0ac42ab6475 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 17 Feb 2011 16:04:54 +0100 Subject: [PATCH 48/55] use '-' instead of '~' in progress bar --- deluge/ui/console/commands/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/commands/info.py b/deluge/ui/console/commands/info.py index d42a80eeb..cf5ae48bd 100644 --- a/deluge/ui/console/commands/info.py +++ b/deluge/ui/console/commands/info.py @@ -85,7 +85,7 @@ def format_progressbar(progress, width): s = "[" p = int(round((progress/100) * w)) s += "#" * p - s += "~" * (w - p) + s += "-" * (w - p) s += "]" return s From e1a3a431f07db9c6ca3db41b6b2cb6d54812600a Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 17 Feb 2011 16:26:58 +0100 Subject: [PATCH 49/55] support command line options again --- deluge/ui/console/commander.py | 145 ++++++++++++++++++++++++++++ deluge/ui/console/main.py | 166 ++++++++++++++++++++++++++++++++- 2 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 deluge/ui/console/commander.py diff --git a/deluge/ui/console/commander.py b/deluge/ui/console/commander.py new file mode 100644 index 000000000..5af4bb908 --- /dev/null +++ b/deluge/ui/console/commander.py @@ -0,0 +1,145 @@ +# +# commander.py +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# Copyright (C) 2011 Nick Lanham +# +# Deluge is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# deluge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. +# +# + +from twisted.internet import defer, reactor +import deluge.component as component +from deluge.ui.client import client +from deluge.ui.console import UI_PATH +from colors import strip_colors + +import logging +log = logging.getLogger(__name__) + +class Commander: + def __init__(self, cmds): + self._commands = cmds + self.console = component.get("ConsoleUI") + + def write(self,line): + print(strip_colors(line)) + + def do_command(self, cmd): + """ + Processes a command. + + :param cmd: str, the command string + + """ + if not cmd: + return + cmd, _, line = cmd.partition(' ') + try: + parser = self._commands[cmd].create_parser() + except KeyError: + self.write("{!error!}Unknown command: %s" % cmd) + return + args = self._commands[cmd].split(line) + + # Do a little hack here to print 'command --help' properly + parser._print_help = parser.print_help + def print_help(f=None): + if self.interactive: + self.write(parser.format_help()) + else: + parser._print_help(f) + parser.print_help = print_help + + # Only these commands can be run when not connected to a daemon + not_connected_cmds = ["help", "connect", "quit"] + aliases = [] + for c in not_connected_cmds: + aliases.extend(self._commands[c].aliases) + not_connected_cmds.extend(aliases) + + if not client.connected() and cmd not in not_connected_cmds: + self.write("{!error!}Not connected to a daemon, please use the connect command first.") + return + + try: + options, args = parser.parse_args(args) + except Exception, e: + self.write("{!error!}Error parsing options: %s" % e) + return + + if not getattr(options, '_exit', False): + try: + ret = self._commands[cmd].handle(*args, **options.__dict__) + except Exception, e: + self.write("{!error!}" + str(e)) + log.exception(e) + import traceback + self.write("%s" % traceback.format_exc()) + return defer.succeed(True) + else: + return ret + + def exec_args(self,args,host,port,username,password): + def on_connect(result): + def on_started(result): + def on_started(result): + deferreds = [] + # If we have args, lets process them and quit + # allow multiple commands split by ";" + for arg in args.split(";"): + deferreds.append(defer.maybeDeferred(self.do_command, arg.strip())) + + def on_complete(result): + self.do_command("quit") + + dl = defer.DeferredList(deferreds).addCallback(on_complete) + + # We need to wait for the rpcs in start() to finish before processing + # any of the commands. + self.console.started_deferred.addCallback(on_started) + component.start().addCallback(on_started) + + def on_connect_fail(result): + from deluge.ui.client import DelugeRPCError + if isinstance(result.value,DelugeRPCError): + rm = result.value.exception_msg + else: + rm = result.getErrorMessage() + print "Could not connect to: %s:%d\n %s"%(host,port,rm) + self.do_command("quit") + + if host: + d = client.connect(host,port,username,password) + else: + d = client.connect() + d.addCallback(on_connect) + d.addErrback(on_connect_fail) + diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index cb252d3fb..cdfb35242 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -53,6 +53,7 @@ from deluge.ui.console.eventlog import EventLog #import screen import colors from deluge.ui.ui import _UI +from deluge.ui.console import UI_PATH log = logging.getLogger(__name__) @@ -62,11 +63,62 @@ class Console(_UI): def __init__(self): super(Console, self).__init__("console") + group = optparse.OptionGroup(self.parser, "Console Options","These options control how " + "the console connects to the daemon. These options will be " + "used if you pass a command, or if you have autoconnect " + "enabled for the console ui.") + + group.add_option("-d","--daemon",dest="daemon_addr", + action="store",type="str",default="127.0.0.1", + help="Set the address of the daemon to connect to." + " [default: %default]") + group.add_option("-p","--port",dest="daemon_port", + help="Set the port to connect to the daemon on. [default: %default]", + action="store",type="int",default=58846) + group.add_option("-u","--username",dest="daemon_user", + help="Set the username to connect to the daemon with. [default: %default]", + action="store",type="string") + group.add_option("-P","--password",dest="daemon_pass", + help="Set the password to connect to the daemon with. [default: %default]", + action="store",type="string") + self.parser.add_option_group(group) + + self.cmds = load_commands(os.path.join(UI_PATH, 'commands')) + class CommandOptionGroup(optparse.OptionGroup): + def __init__(self, parser, title, description=None, cmds = None): + optparse.OptionGroup.__init__(self,parser,title,description) + self.cmds = cmds + + def format_help(self, formatter): + result = formatter.format_heading(self.title) + formatter.indent() + if self.description: + result += "%s\n"%formatter.format_description(self.description) + for cname in self.cmds: + cmd = self.cmds[cname] + if cmd.interactive_only or cname in cmd.aliases: continue + allnames = [cname] + allnames.extend(cmd.aliases) + cname = "/".join(allnames) + result += formatter.format_heading(" - ".join([cname,cmd.__doc__])) + formatter.indent() + result += "%*s%s\n" % (formatter.current_indent, "", cmd.usage) + formatter.dedent() + formatter.dedent() + return result + cmd_group = CommandOptionGroup(self.parser, "Console Commands", + description="The following commands can be issued at the " + "command line. Commands should be quoted, so, for example, " + "to pause torrent with id 'abc' you would run: '%s " + "\"pause abc\"'"%os.path.basename(sys.argv[0]), + cmds=self.cmds) + self.parser.add_option_group(cmd_group) def start(self): super(Console, self).start() - - ConsoleUI(self.args) + ConsoleUI(self.args,self.cmds,(self.options.daemon_addr, + self.options.daemon_port,self.options.daemon_user, + self.options.daemon_pass)) def start(): Console().start() @@ -88,8 +140,61 @@ class OptionParser(optparse.OptionParser): raise Exception(msg) +class BaseCommand(object): + + usage = 'usage' + interactive_only = False + option_list = tuple() + aliases = [] + + def complete(self, text, *args): + return [] + def handle(self, *args, **options): + pass + + @property + def name(self): + return 'base' + + @property + def epilog(self): + return self.__doc__ + + def split(self, text): + if deluge.common.windows_check(): + text = text.replace('\\', '\\\\') + return shlex.split(text) + + def create_parser(self): + return OptionParser(prog = self.name, + usage = self.usage, + epilog = self.epilog, + option_list = self.option_list) + + +def load_commands(command_dir, exclude=[]): + def get_command(name): + return getattr(__import__('deluge.ui.console.commands.%s' % name, {}, {}, ['Command']), 'Command')() + + try: + commands = [] + for filename in os.listdir(command_dir): + if filename.split('.')[0] in exclude or filename.startswith('_'): + continue + if not (filename.endswith('.py') or filename.endswith('.pyc')): + continue + cmd = get_command(filename.split('.')[len(filename.split('.')) - 2]) + aliases = [ filename.split('.')[len(filename.split('.')) - 2] ] + aliases.extend(cmd.aliases) + for a in aliases: + commands.append((a, cmd)) + return dict(commands) + except OSError, e: + return {} + + class ConsoleUI(component.Component): - def __init__(self, args=None): + def __init__(self, args=None, cmds = None, daemon = None): component.Component.__init__(self, "ConsoleUI", 2) # keep track of events for the log view @@ -114,6 +219,18 @@ class ConsoleUI(component.Component): if args: args = args[0] self.interactive = False + if not cmds: + print "Sorry, couldn't find any commands" + return + else: + self._commands = cmds + from commander import Commander + cmdr = Commander(cmds) + if daemon: + cmdr.exec_args(args,*daemon) + else: + cmdr.exec_args(args,None,None,None,None) + self.coreconfig = CoreConfig() if self.interactive and not deluge.common.windows_check(): @@ -155,6 +272,44 @@ class ConsoleUI(component.Component): reactor.run() + def start(self): + # Maintain a list of (torrent_id, name) for use in tab completion + if not self.interactive: + self.started_deferred = defer.Deferred() + self.torrents = [] + def on_session_state(result): + def on_torrents_status(torrents): + for torrent_id, status in torrents.items(): + self.torrents.append((torrent_id, status["name"])) + self.started_deferred.callback(True) + + client.core.get_torrents_status({"id": result}, ["name"]).addCallback(on_torrents_status) + client.core.get_session_state().addCallback(on_session_state) + + + def match_torrent(self, string): + """ + Returns a list of torrent_id matches for the string. It will search both + torrent_ids and torrent names, but will only return torrent_ids. + + :param string: str, the string to match on + + :returns: list of matching torrent_ids. Will return an empty list if + no matches are found. + + """ + ret = [] + for tid, name in self.torrents: + if tid.startswith(string) or name.startswith(string): + ret.append(tid) + + return ret + + + def set_batch_write(self, batch): + # only kept for legacy reasons, don't actually do anything + pass + def set_mode(self, mode): reactor.removeReader(self.screen) self.screen = mode @@ -165,4 +320,7 @@ class ConsoleUI(component.Component): component.stop() def write(self, s): - self.events.append(s) + if self.interactive: + self.events.append(s) + else: + print colors.strip_colors(s) From 3db7bcbfc765ac7d633cb101e38238ed04e1703e Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 21 Feb 2011 16:30:49 +0100 Subject: [PATCH 50/55] make message popups a bit more sane --- deluge/ui/console/modes/popup.py | 37 +++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py index 7cc468a1d..0702801d1 100644 --- a/deluge/ui/console/modes/popup.py +++ b/deluge/ui/console/modes/popup.py @@ -259,25 +259,36 @@ class MessagePopup(Popup): def __init__(self, parent_mode, title, message): self.message = message self.width= int(parent_mode.cols/2) - lns = self._split_message(self.message) - height = max(len(lns),self._min_height) - Popup.__init__(self,parent_mode,title,height_req=(height+2)) - lft = height - len(lns) - if lft: - for i in range(0,int(lft/2)): - lns.insert(0,"") + lns = self._split_message() + Popup.__init__(self,parent_mode,title,height_req=len(lns)) self._lines = lns - def _split_message(self,message): + def _split_message(self): ret = [] wl = (self.width-2) - for i in range(0,len(self.message),wl): - l = self.message[i:i+wl] - lp = (wl-len(self._strip_re.sub('',l)))/2 - ret.append("%s%s"%(lp*" ",l)) + + s1 = self.message.split("\n") + + for s in s1: + while len(self._strip_re.sub('',s)) > wl: + sidx = s.rfind(" ",0,wl-1) + sidx += 1 + if sidx > 0: + ret.append(s[0:sidx]) + s = s[sidx:] + else: + # can't find a reasonable split, just split at width + ret.append(s[0:wl]) + s = s[wl:] + if s: + ret.append(s) + + for i in range(len(ret),self._min_height): + ret.append(" ") + return ret def handle_resize(self): Popup.handle_resize(self) self.clear() - self._lines = self._split_message(self.message) + self._lines = self._split_message() From e16ee523a54e9d81eb86e94626daf7e3dbb0603a Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 21 Feb 2011 17:41:10 +0100 Subject: [PATCH 51/55] fix messagepopup height_req --- deluge/ui/console/modes/popup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py index 0702801d1..0bb2a04c0 100644 --- a/deluge/ui/console/modes/popup.py +++ b/deluge/ui/console/modes/popup.py @@ -260,7 +260,7 @@ class MessagePopup(Popup): self.message = message self.width= int(parent_mode.cols/2) lns = self._split_message() - Popup.__init__(self,parent_mode,title,height_req=len(lns)) + Popup.__init__(self,parent_mode,title,height_req=(len(lns)+2)) self._lines = lns def _split_message(self): From d9d8762c8ea67ec300946894303715b8ea9f644d Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 21 Feb 2011 17:41:28 +0100 Subject: [PATCH 52/55] add get_torrent_name --- deluge/ui/console/main.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index cdfb35242..e7c743245 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -306,6 +306,17 @@ class ConsoleUI(component.Component): return ret + def get_torrent_name(self, torrent_id): + if self.interactive and hasattr(self.screen,"get_torrent_name"): + return self.screen.get_torrent_name(torrent_id) + + for tid, name in self.torrents: + if torrent_id == tid: + return name + + return None + + def set_batch_write(self, batch): # only kept for legacy reasons, don't actually do anything pass From 3da5cd9816c9ddc5ea30779934d6e1a65e03d399 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 21 Feb 2011 17:43:35 +0100 Subject: [PATCH 53/55] add get_torrent_name --- deluge/ui/console/modes/alltorrents.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index e9d1899d2..ee57a59e5 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -258,6 +258,12 @@ class AllTorrents(BaseMode): if refresh: self.refresh() + def get_torrent_name(self, torrent_id): + for p,i in enumerate(self._sorted_ids): + if torrent_id == i: + return self.torrent_names[p] + return None + def _scroll_up(self, by): prevoff = self.curoff self.cursel = max(self.cursel - by,1) From 1173f1c714f63ea03e25461e1ee6da7dc98a126f Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 21 Feb 2011 17:44:12 +0100 Subject: [PATCH 54/55] support globbing for multi-file add and have better fail reports --- deluge/ui/console/modes/add_util.py | 50 ++++++++++++++++---------- deluge/ui/console/modes/alltorrents.py | 36 +++++++++++++++---- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/deluge/ui/console/modes/add_util.py b/deluge/ui/console/modes/add_util.py index 28372631d..49720df97 100644 --- a/deluge/ui/console/modes/add_util.py +++ b/deluge/ui/console/modes/add_util.py @@ -42,31 +42,43 @@ from deluge.ui.client import client import deluge.component as component from optparse import make_option -import os -import base64 +import os,base64,glob -def add_torrent(t_file, options, success_cb, fail_cb): +try: + import libtorrent + add_get_info = libtorrent.torrent_info +except: + add_get_info = deluge.ui.common.TorrentInfo + +def add_torrent(t_file, options, success_cb, fail_cb, ress): t_options = {} if options["path"]: t_options["download_location"] = os.path.expanduser(options["path"]) t_options["add_paused"] = options["add_paused"] - # Keep a list of deferreds to make a DeferredList - if not os.path.exists(t_file): - fail_cb("{!error!}%s doesn't exist!" % t_file) - return - if not os.path.isfile(t_file): - fail_cb("{!error!}%s is a directory!" % t_file) - return - + files = glob.glob(t_file) + num_files = len(files) + ress["total"] = num_files - filename = os.path.split(t_file)[-1] - filedump = base64.encodestring(open(t_file).read()) + if num_files <= 0: + fail_cb("Doesn't exist",t_file,ress) - def on_success(result): - success_cb("{!success!}Torrent added!") - def on_fail(result): - fail_cb("{!error!}Torrent was not added! %s" % result) + for f in files: + if not os.path.exists(f): + fail_cb("Doesn't exist",f,ress) + continue + if not os.path.isfile(f): + fail_cb("Is a directory",f,ress) + continue + + try: + add_get_info(f) + except Exception as e: + fail_cb(e.message,f,ress) + continue + + filename = os.path.split(f)[-1] + filedump = base64.encodestring(open(f).read()) + + client.core.add_torrent_file(filename, filedump, t_options).addCallback(success_cb,f,ress).addErrback(fail_cb,f,ress) - client.core.add_torrent_file(filename, filedump, t_options).addCallback(on_success).addErrback(on_fail) - diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py index ee57a59e5..578cdbf33 100644 --- a/deluge/ui/console/modes/alltorrents.py +++ b/deluge/ui/console/modes/alltorrents.py @@ -412,13 +412,37 @@ class AllTorrents(BaseMode): self.popup.add_line("_Checking",data=FILTER.CHECKING,foreground="blue") self.popup.add_line("Q_ueued",data=FILTER.QUEUED,foreground="yellow") + def __report_add_status(self, succ_cnt, fail_cnt, fail_msgs): + if fail_cnt == 0: + self.report_message("Torrents Added","{!success!}Sucessfully added %d torrent(s)"%succ_cnt) + else: + msg = ("{!error!}Failed to add the following %d torrent(s):\n {!error!}"%fail_cnt)+"\n {!error!}".join(fail_msgs) + if succ_cnt != 0: + msg += "\n \n{!success!}Sucessfully added %d torrent(s)"%succ_cnt + self.report_message("Torrent Add Report",msg) + def _do_add(self, result): - log.debug("Adding Torrent: %s (dl path: %s) (paused: %d)",result["file"],result["path"],result["add_paused"]) - def suc_cb(msg): - self.report_message("Torrent Added",msg) - def fail_cb(msg): - self.report_message("Failed To Add Torrent",msg) - add_torrent(result["file"],result,suc_cb,fail_cb) + log.debug("Adding Torrent(s): %s (dl path: %s) (paused: %d)",result["file"],result["path"],result["add_paused"]) + ress = {"succ":0, + "fail":0, + "fmsg":[]} + + def fail_cb(msg,t_file,ress): + log.debug("failed to add torrent: %s: %s"%(t_file,msg)) + ress["fail"]+=1 + ress["fmsg"].append("%s: %s"%(t_file,msg)) + if (ress["succ"]+ress["fail"]) >= ress["total"]: + self.__report_add_status(ress["succ"],ress["fail"],ress["fmsg"]) + def suc_cb(tid,t_file,ress): + if tid: + log.debug("added torrent: %s (%s)"%(t_file,tid)) + ress["succ"]+=1 + if (ress["succ"]+ress["fail"]) >= ress["total"]: + self.__report_add_status(ress["succ"],ress["fail"],ress["fmsg"]) + else: + fail_cb("Already in session (probably)",t_file,ress) + + add_torrent(result["file"],result,suc_cb,fail_cb,ress) def _show_torrent_add_popup(self): dl = "" From b0c561dbbce4dc74b16f13ce24d36c6b01ade3c4 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 22 Feb 2011 00:09:27 +0100 Subject: [PATCH 55/55] add add_checked_input --- deluge/ui/console/modes/input_popup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py index b59c914cd..183d33d95 100644 --- a/deluge/ui/console/modes/input_popup.py +++ b/deluge/ui/console/modes/input_popup.py @@ -577,6 +577,9 @@ class InputPopup(Popup): def add_select_input(self, message, name, opts, vals, default_index=0): self.inputs.append(SelectInput(self.parent, message, name, opts, vals, default_index)) + def add_checked_input(self, message, name, checked=False): + self.inputs.append(CheckedInput(self.parent,message,name,checked)) + def _refresh_lines(self): self._cursor_row = -1 self._cursor_col = -1