Merge pull request #2388 from cortesi/consolebits

A few console-related bits and bobs
This commit is contained in:
Aldo Cortesi 2017-06-11 22:35:05 +12:00 committed by GitHub
commit c03f14cba6
19 changed files with 70 additions and 246 deletions

View File

@ -1,2 +1,3 @@
def original_addr(csock): def original_addr(csock):
return csock.getsockname() return csock.getsockname()

View File

@ -99,7 +99,7 @@ class CommandsList(urwid.ListBox):
super().__init__(self.walker) super().__init__(self.walker)
def keypress(self, size, key): def keypress(self, size, key):
if key == "enter": if key == "m_select":
foc, idx = self.get_focus() foc, idx = self.get_focus()
signals.status_prompt_command.send(partial=foc.cmd.path + " ") signals.status_prompt_command.send(partial=foc.cmd.path + " ")
elif key == "m_start": elif key == "m_start":
@ -148,7 +148,7 @@ class Commands(urwid.Pile, layoutwidget.LayoutWidget):
self.master = master self.master = master
def keypress(self, size, key): def keypress(self, size, key):
if key == "tab": if key == "m_next":
self.focus_position = ( self.focus_position = (
self.focus_position + 1 self.focus_position + 1
) % len(self.widget_list) ) % len(self.widget_list)

View File

@ -17,6 +17,8 @@ def map(km):
km.add("j", "console.nav.down", ["global"], "Down") km.add("j", "console.nav.down", ["global"], "Down")
km.add("l", "console.nav.right", ["global"], "Right") km.add("l", "console.nav.right", ["global"], "Right")
km.add("h", "console.nav.left", ["global"], "Left") km.add("h", "console.nav.left", ["global"], "Left")
km.add("tab", "console.nav.next", ["global"], "Next")
km.add("enter", "console.nav.select", ["global"], "Select")
km.add(" ", "console.nav.pagedown", ["global"], "Page down") km.add(" ", "console.nav.pagedown", ["global"], "Page down")
km.add("ctrl f", "console.nav.pagedown", ["global"], "Page down") km.add("ctrl f", "console.nav.pagedown", ["global"], "Page down")
km.add("ctrl b", "console.nav.pageup", ["global"], "Page up") km.add("ctrl b", "console.nav.pageup", ["global"], "Page up")
@ -78,7 +80,6 @@ def map(km):
["flowlist", "flowview"], ["flowlist", "flowview"],
"Run a script on this flow" "Run a script on this flow"
) )
km.add("enter", "console.view.flow @focus", ["flowlist"], "View this flow")
km.add( km.add(
"e", "e",
@ -105,7 +106,6 @@ def map(km):
) )
km.add("p", "view.focus.prev", ["flowview"], "Go to previous flow") km.add("p", "view.focus.prev", ["flowview"], "Go to previous flow")
km.add("m", "console.flowview.mode.set", ["flowview"], "Set flow view mode") km.add("m", "console.flowview.mode.set", ["flowview"], "Set flow view mode")
km.add("tab", "console.nav.right", ["flowview"], "Go to next tab")
km.add( km.add(
"z", "z",
"console.choose \"Part\" request,response " "console.choose \"Part\" request,response "
@ -121,7 +121,6 @@ def map(km):
km.add("a", "console.grideditor.add", ["grideditor"], "Add a row after cursor") km.add("a", "console.grideditor.add", ["grideditor"], "Add a row after cursor")
km.add("A", "console.grideditor.insert", ["grideditor"], "Insert a row before cursor") km.add("A", "console.grideditor.insert", ["grideditor"], "Insert a row before cursor")
km.add("tab", "console.grideditor.next", ["grideditor"], "Go to next field")
km.add("d", "console.grideditor.delete", ["grideditor"], "Delete this row") km.add("d", "console.grideditor.delete", ["grideditor"], "Delete this row")
km.add( km.add(
"r", "r",
@ -136,3 +135,5 @@ def map(km):
"Read a Python-style escaped string from file" "Read a Python-style escaped string from file"
) )
km.add("e", "console.grideditor.editor", ["grideditor"], "Edit in external editor") km.add("e", "console.grideditor.editor", ["grideditor"], "Edit in external editor")
km.add("z", "console.eventlog.clear", ["eventlog"], "Clear")

View File

@ -18,16 +18,14 @@ class EventLog(urwid.ListBox, layoutwidget.LayoutWidget):
self.master = master self.master = master
urwid.ListBox.__init__(self, self.walker) urwid.ListBox.__init__(self, self.walker)
signals.sig_add_log.connect(self.sig_add_log) signals.sig_add_log.connect(self.sig_add_log)
signals.sig_clear_log.connect(self.sig_clear_log)
def set_focus(self, index): def set_focus(self, index):
if 0 <= index < len(self.walker): if 0 <= index < len(self.walker):
super().set_focus(index) super().set_focus(index)
def keypress(self, size, key): def keypress(self, size, key):
if key == "z": if key == "m_end":
self.clear_events()
key = None
elif key == "m_end":
self.set_focus(len(self.walker) - 1) self.set_focus(len(self.walker) - 1)
elif key == "m_start": elif key == "m_start":
self.set_focus(0) self.set_focus(0)
@ -45,5 +43,5 @@ class EventLog(urwid.ListBox, layoutwidget.LayoutWidget):
if self.master.options.console_focus_follow: if self.master.options.console_focus_follow:
self.walker.set_focus(len(self.walker) - 1) self.walker.set_focus(len(self.walker) - 1)
def clear_events(self): def sig_clear_log(self, sender):
self.walker[:] = [] self.walker[:] = []

View File

@ -82,6 +82,8 @@ class FlowListBox(urwid.ListBox, layoutwidget.LayoutWidget):
self.master.commands.call("view.go 0") self.master.commands.call("view.go 0")
elif key == "m_end": elif key == "m_end":
self.master.commands.call("view.go -1") self.master.commands.call("view.go -1")
elif key == "m_select":
self.master.commands.call("console.view.flow @focus")
return urwid.ListBox.keypress(self, size, key) return urwid.ListBox.keypress(self, size, key)
def view_changed(self): def view_changed(self):

View File

@ -194,10 +194,6 @@ class FlowDetails(tabs.Tabs):
] ]
return searchable.Searchable(txt) return searchable.Searchable(txt)
def keypress(self, size, key):
key = super().keypress(size, key)
return self._w.keypress(size, key)
class FlowView(urwid.Frame, layoutwidget.LayoutWidget): class FlowView(urwid.Frame, layoutwidget.LayoutWidget):
keyctx = "flowview" keyctx = "flowview"

View File

@ -6,19 +6,10 @@ import urwid
from mitmproxy.utils import strutils from mitmproxy.utils import strutils
from mitmproxy import exceptions from mitmproxy import exceptions
from mitmproxy.tools.console import common
from mitmproxy.tools.console import signals from mitmproxy.tools.console import signals
from mitmproxy.tools.console import layoutwidget from mitmproxy.tools.console import layoutwidget
import mitmproxy.tools.console.master # noqa import mitmproxy.tools.console.master # noqa
FOOTER = [
('heading_key', "enter"), ":edit ",
('heading_key', "q"), ":back ",
]
FOOTER_EDITING = [
('heading_key', "esc"), ":stop editing ",
]
def read_file(filename: str, escaped: bool) -> typing.AnyStr: def read_file(filename: str, escaped: bool) -> typing.AnyStr:
filename = os.path.expanduser(filename) filename = os.path.expanduser(filename)
@ -197,12 +188,10 @@ class GridWalker(urwid.ListWalker):
self.edit_row = GridRow( self.edit_row = GridRow(
self.focus_col, True, self.editor, self.lst[self.focus] self.focus_col, True, self.editor, self.lst[self.focus]
) )
signals.footer_help.send(self, helptext=FOOTER_EDITING)
self._modified() self._modified()
def stop_edit(self): def stop_edit(self):
if self.edit_row: if self.edit_row:
signals.footer_help.send(self, helptext=FOOTER)
try: try:
val = self.edit_row.edit_col.get_data() val = self.edit_row.edit_col.get_data()
except ValueError: except ValueError:
@ -262,7 +251,6 @@ class GridListBox(urwid.ListBox):
FIRST_WIDTH_MAX = 40 FIRST_WIDTH_MAX = 40
FIRST_WIDTH_MIN = 20
class BaseGridEditor(urwid.WidgetWrap): class BaseGridEditor(urwid.WidgetWrap):
@ -315,7 +303,6 @@ class BaseGridEditor(urwid.WidgetWrap):
w = urwid.Frame(self.lb, header=h) w = urwid.Frame(self.lb, header=h)
super().__init__(w) super().__init__(w)
signals.footer_help.send(self, helptext="")
self.show_empty_msg() self.show_empty_msg()
def layout_popping(self): def layout_popping(self):
@ -344,7 +331,7 @@ class BaseGridEditor(urwid.WidgetWrap):
def keypress(self, size, key): def keypress(self, size, key):
if self.walker.edit_row: if self.walker.edit_row:
if key in ["esc"]: if key == "esc":
self.walker.stop_edit() self.walker.stop_edit()
elif key == "tab": elif key == "tab":
pf, pfc = self.walker.focus, self.walker.focus_col pf, pfc = self.walker.focus, self.walker.focus_col
@ -358,6 +345,8 @@ class BaseGridEditor(urwid.WidgetWrap):
column = self.columns[self.walker.focus_col] column = self.columns[self.walker.focus_col]
if key == "m_start": if key == "m_start":
self.walker.set_focus(0) self.walker.set_focus(0)
elif key == "m_next":
self.walker.tab_next()
elif key == "m_end": elif key == "m_end":
self.walker.set_focus(len(self.walker.lst) - 1) self.walker.set_focus(len(self.walker.lst) - 1)
elif key == "left": elif key == "left":
@ -389,38 +378,6 @@ class BaseGridEditor(urwid.WidgetWrap):
def handle_key(self, key): def handle_key(self, key):
return False return False
def make_help(self):
text = [
urwid.Text([("text", "Editor control:\n")])
]
keys = [
("A", "insert row before cursor"),
("a", "add row after cursor"),
("d", "delete row"),
("e", "spawn external editor on current field"),
("q", "save changes and exit editor"),
("r", "read value from file"),
("R", "read unescaped value from file"),
("esc", "save changes and exit editor"),
("tab", "next field"),
("enter", "edit field"),
]
text.extend(
common.format_keyvals(keys, key="key", val="text", indent=4)
)
text.append(
urwid.Text(
[
"\n",
("text", "Values are escaped Python-style strings.\n"),
]
)
)
return text
def cmd_next(self):
self.walker.tab_next()
def cmd_add(self): def cmd_add(self):
self.walker.add() self.walker.add()

View File

@ -15,7 +15,7 @@ class Column(base.Column):
return b"" return b""
def keypress(self, key, editor): def keypress(self, key, editor):
if key in ["enter"]: if key in ["m_select"]:
editor.walker.start_edit() editor.walker.start_edit()
else: else:
return key return key

View File

@ -26,7 +26,7 @@ class Column(base.Column):
expire=1000 expire=1000
) )
return return
elif key in ["enter"]: elif key == "m_select":
editor.master.view_grideditor( editor.master.view_grideditor(
self.subeditor( self.subeditor(
editor.master, editor.master,

View File

@ -1,8 +1,5 @@
import urwid
from mitmproxy import exceptions from mitmproxy import exceptions
from mitmproxy.tools.console import common
from mitmproxy.tools.console import layoutwidget from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console.grideditor import base from mitmproxy.tools.console.grideditor import base
from mitmproxy.tools.console.grideditor import col_text from mitmproxy.tools.console.grideditor import col_text
@ -32,20 +29,6 @@ class HeaderEditor(base.FocusEditor):
col_bytes.Column("Value") col_bytes.Column("Value")
] ]
def make_help(self):
h = super().make_help()
text = [
urwid.Text([("text", "Special keys:\n")])
]
keys = [
]
text.extend(
common.format_keyvals(keys, key="key", val="text", indent=4)
)
text.append(urwid.Text([("text", "\n")]))
text.extend(h)
return text
class RequestHeaderEditor(HeaderEditor): class RequestHeaderEditor(HeaderEditor):
title = "Edit Request Headers" title = "Edit Request Headers"

View File

@ -6,6 +6,21 @@ from mitmproxy.tools.console import layoutwidget
from mitmproxy.tools.console import tabs from mitmproxy.tools.console import tabs
class CListBox(urwid.ListBox):
def __init__(self, contents):
self.length = len(contents)
contents = contents[:] + [urwid.Text(["\n"])] * 5
super().__init__(contents)
def keypress(self, size, key):
if key == "m_end":
self.set_focus(self.length - 1)
elif key == "m_start":
self.set_focus(0)
else:
return super().keypress(size, key)
class HelpView(tabs.Tabs, layoutwidget.LayoutWidget): class HelpView(tabs.Tabs, layoutwidget.LayoutWidget):
title = "Help" title = "Help"
keyctx = "help" keyctx = "help"
@ -54,7 +69,7 @@ class HelpView(tabs.Tabs, layoutwidget.LayoutWidget):
text.extend(self.format_keys(self.master.keymap.list("global"))) text.extend(self.format_keys(self.master.keymap.list("global")))
return urwid.ListBox(text) return CListBox(text)
def filtexp_title(self): def filtexp_title(self):
return "Filter Expressions" return "Filter Expressions"
@ -83,7 +98,7 @@ class HelpView(tabs.Tabs, layoutwidget.LayoutWidget):
text.extend( text.extend(
common.format_keyvals(examples, key="key", val="text", indent=4) common.format_keyvals(examples, key="key", val="text", indent=4)
) )
return urwid.ListBox(text) return CListBox(text)
def layout_pushed(self, prev): def layout_pushed(self, prev):
""" """

View File

@ -6,6 +6,7 @@ from mitmproxy.tools.console import commandeditor
SupportedContexts = { SupportedContexts = {
"chooser", "chooser",
"commands", "commands",
"eventlog",
"flowlist", "flowlist",
"flowview", "flowview",
"global", "global",

View File

@ -131,6 +131,20 @@ class ConsoleAddon:
""" """
self.master.inject_key("m_end") self.master.inject_key("m_end")
@command.command("console.nav.next")
def nav_next(self) -> None:
"""
Go to the next navigatable item.
"""
self.master.inject_key("m_next")
@command.command("console.nav.select")
def nav_select(self) -> None:
"""
Select a navigable item for viewing or editing.
"""
self.master.inject_key("m_select")
@command.command("console.nav.up") @command.command("console.nav.up")
def nav_up(self) -> None: def nav_up(self) -> None:
""" """
@ -343,13 +357,6 @@ class ConsoleAddon:
""" """
self._grideditor().cmd_insert() self._grideditor().cmd_insert()
@command.command("console.grideditor.next")
def grideditor_next(self) -> None:
"""
Go to next cell.
"""
self._grideditor().cmd_next()
@command.command("console.grideditor.delete") @command.command("console.grideditor.delete")
def grideditor_delete(self) -> None: def grideditor_delete(self) -> None:
""" """
@ -419,6 +426,13 @@ class ConsoleAddon:
] ]
) )
@command.command("console.eventlog.clear")
def eventlog_clear(self) -> None:
"""
Clear the event log.
"""
signals.sig_clear_log.send(self)
def running(self): def running(self):
self.started = True self.started = True

View File

@ -176,8 +176,10 @@ class OptionsList(urwid.ListBox):
except exceptions.OptionsError as v: except exceptions.OptionsError as v:
signals.status_message.send(message=str(v)) signals.status_message.send(message=str(v))
self.walker.stop_editing() self.walker.stop_editing()
return None
elif key == "esc": elif key == "esc":
self.walker.stop_editing() self.walker.stop_editing()
return None
else: else:
if key == "m_start": if key == "m_start":
self.set_focus(0) self.set_focus(0)
@ -185,7 +187,7 @@ class OptionsList(urwid.ListBox):
elif key == "m_end": elif key == "m_end":
self.set_focus(len(self.walker.opts) - 1) self.set_focus(len(self.walker.opts) - 1)
self.walker._modified() self.walker._modified()
elif key == "enter": elif key == "m_select":
foc, idx = self.get_focus() foc, idx = self.get_focus()
if foc.opt.typespec == bool: if foc.opt.typespec == bool:
self.master.options.toggler(foc.opt.name)() self.master.options.toggler(foc.opt.name)()
@ -261,7 +263,7 @@ class Options(urwid.Pile, layoutwidget.LayoutWidget):
return foc.opt.name return foc.opt.name
def keypress(self, size, key): def keypress(self, size, key):
if key == "tab": if key == "m_next":
self.focus_position = ( self.focus_position = (
self.focus_position + 1 self.focus_position + 1
) % len(self.widget_list) ) % len(self.widget_list)

View File

@ -2,7 +2,6 @@ import math
import urwid import urwid
from mitmproxy.tools.console import common
from mitmproxy.tools.console import signals from mitmproxy.tools.console import signals
from mitmproxy.tools.console import grideditor from mitmproxy.tools.console import grideditor
from mitmproxy.tools.console import layoutwidget from mitmproxy.tools.console import layoutwidget
@ -116,20 +115,13 @@ class Chooser(urwid.WidgetWrap, layoutwidget.LayoutWidget):
def keypress(self, size, key): def keypress(self, size, key):
key = self.master.keymap.handle("chooser", key) key = self.master.keymap.handle("chooser", key)
if key == "enter": if key == "m_select":
self.callback(self.choices[self.walker.index]) self.callback(self.choices[self.walker.index])
signals.pop_view_state.send(self) signals.pop_view_state.send(self)
elif key == "esc":
signals.pop_view_state.send(self)
return super().keypress(size, key) return super().keypress(size, key)
def make_help(self):
text = []
keys = [
("enter", "choose option"),
("esc", "exit chooser"),
]
text.extend(common.format_keyvals(keys, key="key", val="text", indent=4))
return text
class OptionsOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget): class OptionsOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget):
keyctx = "grideditor" keyctx = "grideditor"
@ -151,9 +143,6 @@ class OptionsOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget):
) )
self.width = math.ceil(cols * 0.8) self.width = math.ceil(cols * 0.8)
def make_help(self):
return self.ge.make_help()
def key_responder(self): def key_responder(self):
return self.ge.key_responder() return self.ge.key_responder()

View File

@ -1,120 +0,0 @@
import urwid
from mitmproxy.tools.console import common
class _OptionWidget(urwid.WidgetWrap):
def __init__(self, option, text, shortcut, active, focus):
self.option = option
textattr = "text"
keyattr = "key"
if focus and active:
textattr = "option_active_selected"
keyattr = "option_selected_key"
elif focus:
textattr = "option_selected"
keyattr = "option_selected_key"
elif active:
textattr = "option_active"
if shortcut:
text = common.highlight_key(
text,
shortcut,
textattr = textattr,
keyattr = keyattr
)
opt = urwid.Text(text, align="left")
opt = urwid.AttrWrap(opt, textattr)
opt = urwid.Padding(opt, align = "center", width = 40)
urwid.WidgetWrap.__init__(self, opt)
def keypress(self, size, key):
return key
def selectable(self):
return True
class OptionWalker(urwid.ListWalker):
def __init__(self, options):
urwid.ListWalker.__init__(self)
self.options = options
self.focus = 0
def set_focus(self, pos):
self.focus = pos
def get_focus(self):
return self.options[self.focus].render(True), self.focus
def get_next(self, pos):
if pos >= len(self.options) - 1:
return None, None
return self.options[pos + 1].render(False), pos + 1
def get_prev(self, pos):
if pos <= 0:
return None, None
return self.options[pos - 1].render(False), pos - 1
class Heading:
def __init__(self, text):
self.text = text
def render(self, focus):
opt = urwid.Text("\n" + self.text, align="left")
opt = urwid.AttrWrap(opt, "title")
opt = urwid.Padding(opt, align = "center", width = 40)
return opt
def _neg(*args):
return False
class Option:
def __init__(self, text, shortcut, getstate=None, activate=None):
self.text = text
self.shortcut = shortcut
self.getstate = getstate or _neg
self.activate = activate or _neg
def render(self, focus):
return _OptionWidget(
self,
self.text,
self.shortcut,
self.getstate(),
focus)
class Select(urwid.ListBox):
def __init__(self, options):
self.walker = OptionWalker(options)
urwid.ListBox.__init__(
self,
self.walker
)
self.options = options
self.keymap = {}
for i in options:
if hasattr(i, "shortcut") and i.shortcut:
if i.shortcut in self.keymap:
raise ValueError("Duplicate shortcut key: %s" % i.shortcut)
self.keymap[i.shortcut] = i
def keypress(self, size, key):
if key == "enter" or key == " ":
self.get_focus()[0].option.activate()
return None
if key in self.keymap:
self.keymap[key].activate()
self.set_focus(self.options.index(self.keymap[key]))
return None
return super().keypress(size, key)

View File

@ -1,6 +1,9 @@
import blinker import blinker
# Show a status message in the action bar # Clear the eventlog
sig_clear_log = blinker.Signal()
# Add an entry to the eventlog
sig_add_log = blinker.Signal() sig_add_log = blinker.Signal()
@ -33,9 +36,6 @@ call_in = blinker.Signal()
# Focus the body, footer or header of the main window # Focus the body, footer or header of the main window
focus = blinker.Signal() focus = blinker.Signal()
# Set the mini help text in the footer of the main window
footer_help = blinker.Signal()
# Fired when settings change # Fired when settings change
update_settings = blinker.Signal() update_settings = blinker.Signal()

View File

@ -3,7 +3,6 @@ import os.path
import urwid import urwid
from mitmproxy.tools.console import common from mitmproxy.tools.console import common
from mitmproxy.tools.console import pathedit
from mitmproxy.tools.console import signals from mitmproxy.tools.console import signals
from mitmproxy.tools.console import commandeditor from mitmproxy.tools.console import commandeditor
import mitmproxy.tools.console.master # noqa import mitmproxy.tools.console.master # noqa
@ -39,16 +38,12 @@ class ActionBar(urwid.WidgetWrap):
self.clear() self.clear()
signals.status_message.connect(self.sig_message) signals.status_message.connect(self.sig_message)
signals.status_prompt.connect(self.sig_prompt) signals.status_prompt.connect(self.sig_prompt)
signals.status_prompt_path.connect(self.sig_path_prompt)
signals.status_prompt_onekey.connect(self.sig_prompt_onekey) signals.status_prompt_onekey.connect(self.sig_prompt_onekey)
signals.status_prompt_command.connect(self.sig_prompt_command) signals.status_prompt_command.connect(self.sig_prompt_command)
self.last_path = ""
self.prompting = None self.prompting = None
self.onekey = False self.onekey = False
self.pathprompt = False
def sig_message(self, sender, message, expire=1): def sig_message(self, sender, message, expire=1):
if self.prompting: if self.prompting:
@ -74,15 +69,6 @@ class ActionBar(urwid.WidgetWrap):
self._w = commandeditor.CommandEdit(partial) self._w = commandeditor.CommandEdit(partial)
self.prompting = commandeditor.CommandExecutor(self.master) self.prompting = commandeditor.CommandExecutor(self.master)
def sig_path_prompt(self, sender, prompt, callback, args=()):
signals.focus.send(self, section="footer")
self._w = pathedit.PathEdit(
self.prep_prompt(prompt),
os.path.dirname(self.last_path)
)
self.pathprompt = True
self.prompting = PromptPath(callback, args)
def sig_prompt_onekey(self, sender, prompt, keys, callback, args=()): def sig_prompt_onekey(self, sender, prompt, keys, callback, args=()):
""" """
Keys are a set of (word, key) tuples. The appropriate key in the Keys are a set of (word, key) tuples. The appropriate key in the
@ -128,13 +114,10 @@ class ActionBar(urwid.WidgetWrap):
def prompt_done(self): def prompt_done(self):
self.prompting = None self.prompting = None
self.onekey = False self.onekey = False
self.pathprompt = False
signals.status_message.send(message="") signals.status_message.send(message="")
signals.focus.send(self, section="body") signals.focus.send(self, section="body")
def prompt_execute(self, txt): def prompt_execute(self, txt):
if self.pathprompt:
self.last_path = txt
p = self.prompting p = self.prompting
self.prompt_done() self.prompt_done()
msg = p(txt) msg = p(txt)

View File

@ -35,7 +35,9 @@ class Tabs(urwid.WidgetWrap):
def keypress(self, size, key): def keypress(self, size, key):
n = len(self.tabs) n = len(self.tabs)
if key == "right": if key == "m_next":
self.change_tab((self.tab_offset + 1) % n)
elif key == "right":
self.change_tab((self.tab_offset + 1) % n) self.change_tab((self.tab_offset + 1) % n)
elif key == "left": elif key == "left":
self.change_tab((self.tab_offset - 1) % n) self.change_tab((self.tab_offset - 1) % n)