diff --git a/deluge/ui/console/screen.py b/deluge/ui/console/screen.py new file mode 100644 index 000000000..5f429543c --- /dev/null +++ b/deluge/ui/console/screen.py @@ -0,0 +1,267 @@ +# +# 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. +# + +import curses +import colors +from deluge.log import LOG as log +from twisted.internet import reactor + +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' + +LINES_BUFFER_SIZE = 5000 +INPUT_HISTORY_SIZE = 500 + +class Screen(CursesStdIO): + def __init__(self, stdscr, command_parser): + """ + A curses screen designed to run as a reader in a twisted reactor. + + :param command_parser: a function that will be passed a string when the + user hits enter + """ + log.debug("Screen init!") + # Function to be called with commands + self.command_parser = command_parser + self.stdscr = stdscr + # Make the input calls non-blocking + self.stdscr.nodelay(1) + + # Holds the user input and is cleared on 'enter' + self.input = "" + self.input_incomplete = "" + # Keep track of where the cursor is + self.input_cursor = 0 + # Keep a history of inputs + self.input_history = [] + self.input_history_index = 0 + + # Strings for the 2 status bars + self.topbar = "" + self.bottombar = "" + + self.rows, self.cols = self.stdscr.getmaxyx() + + # A list of strings to be displayed based on the offset (scroll) + self.lines = [] + # The offset to display lines + self.display_lines_offset = 0 + + # Create some color pairs, should probably be moved to colors.py + # Regular text + #curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) + # Status bar + #curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLUE) + + + self.refresh() + + def connectionLost(self, reason): + self.close() + + def add_line(self, text): + """ + Add a line to the screen. This will be showed between the two bars. + 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!" + + :param text: str, the text to show + """ + log.debug("adding line: %s", text) + + self.lines.extend(text.splitlines()) + while len(self.lines) > LINES_BUFFER_SIZE: + # Remove the oldest line if the max buffer size has been reached + self.lines.remove(0) + + self.refresh() + + def add_string(self, row, string): + """ + Adds a string to the desired `:param:row`. + + :param row: int, the row number to write the string + + """ + col = 0 + parsed = colors.parse_color_string(string) + for index, (color, s) in enumerate(parsed): + if index + 1 == len(parsed): + # This is the last string so lets append some " " to it + s += " " * (self.cols - (col + len(s)) - 1) + self.stdscr.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 - 2, self.bottombar) + + # The number of rows minus the status bars and the input line + available_lines = self.rows - 3 + # If the amount of lines exceeds the number of rows, we need to figure out + # which ones to display based on the offset + if len(self.lines) > available_lines: + # Get the lines to display based on the offset + offset = len(self.lines) - self.display_lines_offset + lines = self.lines[-(available_lines - offset):offset] + elif len(self.lines) == available_lines: + lines = self.lines + else: + lines = [""] * (available_lines - len(self.lines)) + lines.extend(self.lines) + + # Add the lines to the screen + for index, line in enumerate(lines): + self.add_string(index + 1, line) + + # Move the cursor + self.stdscr.move(self.rows - 1, self.input_cursor) + 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() + + # We clear the input string and send it to the command parser on ENTER + if c == curses.KEY_ENTER or c == 10: + if self.input: + self.add_line(">>> " + self.input) + self.command_parser(self.input) + if len(self.input_history) == INPUT_HISTORY_SIZE: + # Remove the oldest input history if the max history size + # is reached. + self.input_history.remove(0) + self.input_history.append(self.input) + self.input_history_index = len(self.input_history) + self.input = "" + self.input_incomplete = "" + self.input_cursor = 0 + self.stdscr.refresh() + + # We use the UP and DOWN keys to cycle through input history + elif c == curses.KEY_UP: + if self.input_history_index - 1 >= 0: + if self.input_history_index == len(self.input_history): + # We're moving from non-complete input so save it just incase + # we move back down to it. + self.input_incomplete = self.input + # Going back in the history + self.input_history_index -= 1 + self.input = self.input_history[self.input_history_index] + elif c == curses.KEY_DOWN: + if self.input_history_index + 1 < len(self.input_history): + # Going forward in the history + self.input_history_index += 1 + self.input = self.input_history[self.input_history_index] + elif self.input_history_index + 1 == len(self.input_history): + # We're moving back down to an incomplete input + self.input_history_index += 1 + self.input = self.input_incomplete + + # Cursor movement + elif c == curses.KEY_LEFT: + if self.input_cursor: + self.input_cursor -= 1 + elif c == curses.KEY_RIGHT: + if self.input_cursor < len(self.input): + self.input_cursor += 1 + elif c == curses.KEY_HOME: + self.input_cursor = 0 + elif c == curses.KEY_END: + self.input_cursor = len(self.input) + + # Delete a character in the input string based on cursor position + if c == curses.KEY_BACKSPACE or c == 127: + if self.input and self.input_cursor > 0: + self.input = self.input[:self.input_cursor - 1] + self.input[self.input_cursor:] + self.input_cursor -= 1 + + # A key to add to the input string + else: + if c > 31 and c < 127: + if self.input_cursor == len(self.input): + self.input += chr(c) + else: + # Insert into string + self.input = self.input[:self.input_cursor] + chr(c) + self.input[self.input_cursor:] + # Move the cursor forward + self.input_cursor += 1 + + # Update the input string on the screen + #self.stdscr.addstr(self.rows - 1, 0, self.input + " " * (self.cols - len(self.input) - 2), curses.color_pair(1)) + self.add_string(self.rows - 1, self.input) + self.stdscr.move(self.rows - 1, self.input_cursor) + 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/statusbars.py b/deluge/ui/console/statusbars.py new file mode 100644 index 000000000..33228ccd1 --- /dev/null +++ b/deluge/ui/console/statusbars.py @@ -0,0 +1,102 @@ +# +# statusbars.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. +# + + +import deluge.component as component +import deluge.common +from deluge.ui.client import client + +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 + self.download = "" + self.upload = "" + self.dht = 0 + + def start(self): + def on_coreconfig_ready(result): + self.__core_config_ready = True + self.update() + + self.__core_config_ready = False + # We need to add a callback to wait for the CoreConfig to be ready + self.config.start_defer.addCallback(on_coreconfig_ready) + + def update(self): + if not self.__core_config_ready: + return + + if self.config["dht"]: + def on_get_dht_nodes(result): + self.dht = result + client.core.get_dht_nodes().addCallback(on_get_dht_nodes) + + def on_get_num_connections(result): + self.connections = result + client.core.get_num_connections().addCallback(on_get_num_connections) + + def on_get_session_status(status): + self.upload = deluge.common.fsize(status["payload_upload_rate"]) + self.download = deluge.common.fsize(status["payload_download_rate"]) + self.update_statusbars() + + client.core.get_session_status([ + "payload_upload_rate", + "payload_download_rate"]).addCallback(on_get_session_status) + + + def update_statusbars(self): + # Update the topbar string + self.screen.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]) + else: + self.screen.topbar += "Not Connected" + + # Update the bottombar string + self.screen.bottombar = "{{status}}C: %s" % self.connections + + if self.config["max_connections_global"] > -1: + self.screen.bottombar += " (%s)" % self.config["max_connections_global"] + + self.screen.bottombar += " D: %s/s" % self.download + + if self.config["max_download_speed"] > -1: + self.screen.bottombar += " (%s/s)" % deluge.common.fsize(self.config["max_download_speed"]) + + self.screen.bottombar += " U: %s/s" % self.upload + + if self.config["max_upload_speed"] > -1: + self.screen.bottombar += " (%s/s)" % deluge.common.fsize(self.config["max_upload_speed"]) + + if self.config["dht"]: + self.screen.bottombar += " DHT: %s" % self.dht + + self.screen.refresh()