from .parser import * from lighthouse.util import * from lighthouse.util.qt import * from lighthouse.util.disassembler import disassembler #------------------------------------------------------------------------------ # Composing Shell #------------------------------------------------------------------------------ class ComposingShell(QtWidgets.QWidget): """ The ComposingShell UI for interactive coverage composition. This class ties together all the individual components that make up the Composing Shell, wrapping it up in a nice portable widget. This includes the label sitting at the head of the shell, the text box (the shell, a.k.a ComposingLine), and the composition parser. In theory, multiple ComposingShell objects could be instantiated and placed in various dialogs, forms, views, etc. These shells are fairly independent, but obviously must communicate with the director. """ def __init__(self, lctx, table_model, table_view=None): super(ComposingShell, self).__init__() self.setObjectName(self.__class__.__name__) # external entities self._director = lctx.director self._palette = lctx.palette self._table_model = table_model self._table_view = table_view # command / input self._search_text = "" self._command_timer = QtCore.QTimer() # the last known user AST self._last_ast = None # composition parser related members self._parser = CompositionParser() self._parser_error = None self._parsed_tokens = [] self._shorthand = [] # configure the widget for use self._ui_init() self.refresh_theme() #-------------------------------------------------------------------------- # Properties #-------------------------------------------------------------------------- @property def text(self): """ The existing shell text. """ return str(self._line.toPlainText()) #-------------------------------------------------------------------------- # Initialization - UI #-------------------------------------------------------------------------- def _ui_init(self): """ Initialize UI elements. """ # initialize a monospace font to use with our widget(s) self._font = MonospaceFont() self._font.setPointSizeF(normalize_to_dpi(10)) self._font_metrics = QtGui.QFontMetricsF(self._font) # initialize our ui elements self._ui_init_shell() self._ui_init_completer() self._ui_init_signals() self._ui_layout() def _ui_init_shell(self): """ Initialize the shell UI elements. """ # the composer label at the head of the shell self._line_label = QtWidgets.QLabel("Composer") self._line_label.setStyleSheet("QLabel { margin: 0 1ex 0 1ex }") self._line_label.setAlignment(QtCore.Qt.AlignCenter) self._line_label.setFont(self._font) self._line_label.setFixedWidth(self._line_label.sizeHint().width()) # the text box / shell / ComposingLine self._line = ComposingLine() def _ui_init_completer(self): """ Initialize the coverage hint UI elements. """ self._completer_model = QtCore.QStringListModel([]) self._completer = QtWidgets.QCompleter(self) self._completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion) self._completer.setModelSorting(QtWidgets.QCompleter.CaseInsensitivelySortedModel) self._completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) self._completer.setModel(self._completer_model) self._completer.setWrapAround(False) self._completer.popup().setFont(self._font) self._completer.setWidget(self._line) def _ui_init_signals(self): """ Connect UI signals. """ # text changed in the shell self._line.textChanged.connect(self._ui_shell_text_changed) # cursor position changed in the shell self._line.cursorPositionChanged.connect(self._ui_shell_cursor_changed) # return key pressed in the shell self._line.returnPressed.connect(self._ui_shell_return_pressed) # register for cues from the director self._director.coverage_created(self._internal_refresh) self._director.coverage_deleted(self._internal_refresh) self._director.coverage_modified(self._internal_refresh) self._director.coverage_switched(self._coverage_switched) # register for cues from the model self._table_model.layoutChanged.connect(self._ui_shell_text_changed) def _ui_layout(self): """ Layout the major UI elements of the widget. """ # create a qt layout for the 'composer' (the shell) layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(0,0,0,0) # # Shell Layout: # [ [ 'Composer' ][ ComposingLine ... ] ] # layout.addWidget(self._line_label) layout.addWidget(self._line) # apply the widget layout self.setLayout(layout) #-------------------------------------------------------------------------- # Refresh #-------------------------------------------------------------------------- def refresh(self): """ Public refresh of the shell. """ self._internal_refresh() @disassembler.execute_ui def refresh_theme(self): """ Refresh UI facing elements to reflect the current theme. """ assert (self._line and self._completer), "UI not yet initialized..." # configure the shell background & default text color qpal = self._line.palette() qpal.setColor(QtGui.QPalette.Text, self._palette.shell_text) qpal.setColor(QtGui.QPalette.WindowText, self._palette.shell_text) self._line.setPalette(qpal) # set other hard to access shell theme elements self._line.setStyleSheet( "QPlainTextEdit {" " color: %s;" % self._palette.shell_text.name() + # this line ensures the text cursor changes color, with the theme " background-color: %s;" % self._palette.shell_background.name() + " border: 1px solid %s;" % self._palette.shell_border.name() + "} " "QPlainTextEdit:hover, QPlainTextEdit:focus {" " border: 1px solid %s;" % self._palette.shell_border_focus.name() + "}" ) # refresh completer popup style... self._completer.popup().setStyleSheet( "background: %s;" % self._palette.shell_hint_background.name() + "color: %s;" % self._palette.shell_hint_text.name() ) @disassembler.execute_ui def _internal_refresh(self): """ Internal refresh of the shell. """ self._refresh_hint_list() self._ui_shell_text_changed() def _refresh_hint_list(self): """ Refresh the shell coverage hint contents. """ hints = [] self._shorthand = [] # get the detailed coverage strings from the director for x in self._director.coverage_names: hints.append(self._director.get_coverage_string(x)) symbol = self._director.get_shorthand(x) if symbol: self._shorthand.append(symbol) # install the fresh coverage strings to the hint completer dialog self._completer_model.setStringList(hints) # queue a UI coverage hint if necessary self._ui_hint_coverage_refresh() def _coverage_switched(self): """ Handle a coverage switched event. specifically, we want cover the specical case where the hot shell is being switched to. In these cases, we should forcefully clear the 'last' AST so that the full shell expression is re-evaluated and sent forward to the director. this will ensure that the director will evaluate and display the results of the present expression as the 'Hot Shell' is now active. """ if self._director.coverage_name == "Hot Shell": self._last_ast = None self._internal_refresh() #-------------------------------------------------------------------------- # Signal Handlers #-------------------------------------------------------------------------- def _ui_hint_tooltip(self, text, index): """ Display a non-intrusive error tooltip to the user. """ # # hide the coverage hint if it is visible. things can look cluttered # down by the shell if we're trying to show both. # self._ui_hint_coverage_hide() # create a cursor and move it to the parse error location on the shell cursor_tip = QtGui.QTextCursor(self._line.document()) cursor_tip.setPosition(index) # # using our carefully positioned cursor, we can now extract the relative # pixel position of the parse error on the shell and map its global # (absolute) pixel position on the screen. # position = self._line.mapToGlobal(self._line.cursorRect(cursor_tip).topLeft()) # draw the tooltip at the computed parse error position x = QtWidgets.QToolTip.showText(position, text) def _ui_shell_cursor_changed(self): """ Cursor position changed in the shell. """ self._ui_hint_coverage_refresh() def _ui_shell_text_changed(self): """ Text changed in the shell. """ text = self.text # # a Search, eg '/DnsParse_' # if self.is_search(text): self._execute_search(text) self._highlight_search() return # not a search query clear any lingering filters for it else: self._table_model.filter_string("") # # a Jump, eg '0x804010a' or 'sub_1400016F0' # if self.is_jump(text) and self._table_view: self._line_label.setText("Jump") self._highlight_jump() return # # a Composition, eg '(A | B) - C' # self._execute_composition(text) self._highlight_composition() self._ui_hint_coverage_refresh() def _ui_shell_return_pressed(self): """ Return / Enter pressed in the shell. The user pressed 'enter' in the shell, this means we want to try and save their composition as a new coverage set to the director. """ text = self.text # a search query has no accept state, nothing to do if self.is_search(text): return # jump to the function entry containing the requested address if self.is_jump(text) and self._table_view: self._execute_jump(text) return # attempt to save the user crafted composition self._accept_composition() #-------------------------------------------------------------------------- # Search #-------------------------------------------------------------------------- @staticmethod def is_search(text): """ Check if a string (text) looks like a search query. A search query is used to filter functions listed in the coverage overview table based on their name. eg: text = '/DnsParse_' """ return (text and text[0] == "/") def _execute_search(self, text): """ Execute the search semantics. """ self._search_text = text[1:] # # if the user input is only "/" (starting to type something), hint # that they are entering the Search mode. nothing else to do! # if text == "/": self._line_label.setText("Search") return # # stop an existing command timer if there is one running. we are about # to schedule a new one or execute inline. so the old/deferred command # is no longer needed. # self._command_timer.stop() # # if the functions list is HUGE, we want to defer the filtering until # we think the user has stopped typing as each pass may take awhile # to compute (while blocking the main thread...) # if self._director.metadata.is_big(): self._command_timer = singleshot(1000, self._execute_search_internal) self._command_timer.start() # # the database is not *massive*, let's execute the search immediately # else: self._execute_search_internal() # done return def _execute_search_internal(self): """ Execute the actual search filtering & coverage metrics. """ # the given text is a real search query, apply it as a filter now self._table_model.filter_string(self._search_text) # compute coverage % of the visible (filtered) results percent = self._table_model.get_modeled_coverage_percent() # show the coverage % of the search results in the shell label self._line_label.setText("%1.2f%%" % percent) def _highlight_search(self): """ Syntax highlight a search query. """ self._line.setUpdatesEnabled(False) ################# UPDATES DISABLED ################# # clear any existing text colors self._color_clear() # color search based on if there are any matching results if self._table_model.rowCount(): self._color_text(self._palette.shell_text_valid, start=1) else: self._color_text(self._palette.shell_text_invalid, start=1) ################# UPDATES ENABLED ################# self._line.setUpdatesEnabled(True) # done return #-------------------------------------------------------------------------- # Jump #-------------------------------------------------------------------------- def is_jump(self, text): """ Check if a string (text) looks like a jump query. A jump query is used to jump to a function in the coverage overview table based on their address. eg: text = '0x8040100', or 'sub_1400016F0' """ return self._compute_jump(text) != 0 def _compute_jump(self, text): """ Compute the function address destination of a jump target from a string. eg: text = '0x8040100', or 'sub_8040100' --> jump to function 0x8040100 """ text = text.strip() # # if the user input is less than two characters, we automatically # dismiss it as a valid jump target. the primary reasons for this # is to avoid possible shorthand parsing clashes. # # eg: imagine the user has a valid function named 'A' that they want to # jump to - well we actually choose to ignore that request here. # # We favor the importance of shorthand symbols as used in compositions. # if len(text) < 2: return 0 # # attempt to convert the user input from a hex number eg '0x8040105' # to its corresponding function address validated by the director # try: address = int(text, 16) except ValueError: pass else: functions = self._director.metadata.get_functions_containing(address) if functions: return functions[0].address # # the user string did not translate to a parsable hex number (address) # or the function it falls within could not be found in the director. # # attempt to convert the user input from a function name, eg 'main', # or 'sub_1400016F0' to a function address validated by the director. # # special case to make 'sub_*' prefixed user inputs case insensitive if text.lower().startswith("sub_"): # attempt uppercase hex (IDA...) function_metadata = self._director.metadata.get_function_by_name("sub_" + text[4:].upper()) if function_metadata: return function_metadata.address # attempt lowercase hex (Binja...) function_metadata = self._director.metadata.get_function_by_name("sub_" + text[4:].lower()) if function_metadata: return function_metadata.address # # no luck yet, let's just throw the user's raw text at the lookup. this # would probably be a function they renamed, such as 'foobar' # function_metadata = self._director.metadata.get_function_by_name(text) if function_metadata: return function_metadata.address # # the user string did not translate to a function name that could # be found in the director. so I guess they're not trying to jump... # # failure, the user input (text) isn't a jump ... return 0 def _execute_jump(self, text): """ Execute the jump semantics. """ assert self._table_view # retrieve the jump target function_address = self._compute_jump(text) assert function_address # select the function entry in the coverage overview table self._table_view.selectRow(self._table_model.func2row[function_address]) self._table_view.scrollTo( self._table_view.currentIndex(), QtWidgets.QAbstractItemView.PositionAtCenter ) def _highlight_jump(self): """ Syntax highlight a jump query. """ self._line.setUpdatesEnabled(False) ################# UPDATES DISABLED ################# # clear any existing text colors self._color_clear() # color jump self._color_text(self._palette.shell_text_valid) ################# UPDATES ENABLED ################# self._line.setUpdatesEnabled(True) # done return #-------------------------------------------------------------------------- # Composition #-------------------------------------------------------------------------- def _execute_composition(self, text): """ Execute a composition query. """ # reset the shell head text self._line_label.setText("Composer") # attempt to parse & execute a composition try: # clear any previous parse attempts/failures self._parser_error = None # attempt to parse the user input against the composition grammar self._parsed_tokens, ast = self._parser.parse(text, self._shorthand) # if the AST changed since the last parse, inform the director if not ast_equal(self._last_ast, ast): self._director.cache_composition(ast) # save the newly parsed ast self._last_ast = ast # parse failure except ParseError as e: self._parser_error = e # # even though we failed to generate an AST that can be evaluated # by the director, we still want to save the list of tokens parsed. # these tokens will still be used for basic syntax highlighting. # self._parsed_tokens = e.parsed_tokens # done return True def _highlight_composition(self): """ Syntax highlight a composition. """ self._line.setUpdatesEnabled(False) ################# UPDATES DISABLED ################# # clear any existing text colors self._color_clear() # the parse failed, so there will be invalid text to highlight if self._parser_error: self._color_invalid() # paint any valid tokens self._color_tokens() ################# UPDATES ENABLED ################# self._line.setUpdatesEnabled(True) # done return def _accept_composition(self): """ Save the user crafted composition to the director. """ # # if there's an existing parse error on the shell, there's nothing we # can do but pop a hint for the user and have them try again # if self._parser_error: self._ui_hint_tooltip("Invalid Composition", self._parser_error.error_index) return # # While the user is picking a name for the new composite, we might as well # try and compute/cache it asynchronously :-). kick the caching off now. # self._director.cache_composition(self._last_ast, force=True) # # the user has entered a valid composition that we have parsed. we # want to save this to the director, but first we need a name for the # new composition. pop a simple dialog prompting the user for a # composition name # ok, coverage_name = prompt_string( "Composition Name:", "Please enter a name for this composition", "COMP_%s" % self.text ) # # once the naming prompt closes, the composing shell tries to pop # the coverage hint again which can make it annoying and too # aggressive. # # clearing focus on the text line will ensure the hint does not pop # self._line.clearFocus() # # returning back to the naming prompt, if the user did not enter a # coverage name (or hit cancel), we will abort saving the composition # if not (ok and coverage_name): return # # a name was given and all is good, ask the director to save the last # composition under the user specified coverage name # self._director.add_composition(coverage_name, self._last_ast) # switch to the newly created composition self._director.select_coverage(coverage_name) #-------------------------------------------------------------------------- # Coverage Hint #-------------------------------------------------------------------------- def _ui_hint_coverage_refresh(self): """ Draw the coverage hint as applicable. """ # # if the shell is not focused (or empty), don't bother to show a hint # as it frequently gets in the way and is really annoying... # if not (self._line.hasFocus() and self.text): self._ui_hint_coverage_hide() return # # if the text cursor is moving and the user has their left mouse # button held, then they are probably doing a click + drag text # selection so we shouldn't be naggin them with hints and stuff # # without this condition, click+drag selection gets really choppy # if QtWidgets.QApplication.mouseButtons() & QtCore.Qt.LeftButton: self._ui_hint_coverage_hide() return # scrape info from the current shell text state cursor_index = self._line.textCursor().position() text_token = self._get_cursor_coverage_token(cursor_index) # # if the user's text cursor is touching the index that produced the # parse error (assuming there was one) ... # if self._parser_error and self._parser_error.error_index == cursor_index: # # if the parse error indicates the parse failed because it expected # a coverage token but didn't get one, show the complete coverage # list. The user should know their list of options bro. # if self._parser_error.expected == TokenCoverageSingle: self._ui_hint_coverage_show() # # if the user's text cursor is touching a valid coverage token, we want # to pop a hint that shows the details for the coverage matching that # explicit token / shorthand. It's a subtle convenience :-) # elif text_token and (text_token.type == "COVERAGE_TOKEN"): self._ui_hint_coverage_show(text_token.value) # # if the user's text cursor is not touching any text index of interest, # there's no reason for us to show any sort of hints. be sure any hints # are hidden. # else: self._ui_hint_coverage_hide() # done return def _ui_hint_coverage_show(self, prefix=''): """ Show the coverage hint at the shell's cursor position. Optionally, one can specify a prefix (eg, the shorthand 'A') to limit the scope of coverage items hinted. """ # # if the completer is already visible and showing the requested prefix, # then we have nothing to do. this will help mitigate refresh flickers # if self._completer.popup().isVisible() and \ self._completer.completionPrefix() == prefix: return # if there was anything previously selected in the popup, clear it now self._completer.popup().clearSelection() # show only hints matching the given prefix # eg: prefix = 'A' will show only entry 'A - 42.30% - drcov.8...' self._completer.setCompletionPrefix(prefix) # specify the position and size of the hint popup cr = self._line.cursorRect() cr.setWidth(self._completer.popup().sizeHintForColumn(0)) # show the coverage hint popup self._completer.complete(cr) self._completer.popup().repaint() # reduces hint flicker on the Hot Shell # done return def _ui_hint_coverage_hide(self): """ Hide the coverage hint. """ self._completer.popup().hide() def _get_cursor_coverage_token(self, index): """ Get the coverage token touching the cursor (if there is one). """ # iterate through the list of parsed tokens on the line edit / shell for text_token in self._parsed_tokens: # skip any non-coverage text tokens if not text_token.type == "COVERAGE_TOKEN": continue # if this coverage text token touches our cursor, return it if text_token.span[0] <= index <= text_token.span[1]: return text_token # no coverage token on either side of the cursor return None #-------------------------------------------------------------------------- # Composition Highlighting #-------------------------------------------------------------------------- def _color_tokens(self): """ Syntax highlight the valid composition tokens. """ # more code-friendly, readable aliases TOKEN_COLORS = self._palette.TOKEN_COLORS # # in order to syntax highlight text of interest, we must use a text # cursor as the vehicle to move around the text box (shell) and # manipulate its contents (eg, painting colors) # # this is simply the way Qt exposes this functionality # # alias the user cursor, and save its original (current) position cursor = self._line.textCursor() cursor_position = cursor.position() # configure text formatting properties we want our cursor to apply highlight = QtGui.QTextCharFormat() highlight.setFontWeight(QtGui.QFont.Bold) # bolds text we 'type' # # we are about to start painting our text, but we want to disable the # shell from emitting any textChanged/cursorMoved kind of signals # that originate from our painting code. # # we use the blockSignals gateways below to disable/enable the signals # for the duration of our painting. # self._line.blockSignals(True) ################# UPDATES DISABLED ################# # iterate through every parsed token, and paint it for token in self._parsed_tokens: # if the palette doesn't define a color for this token, ignore it if token.type not in TOKEN_COLORS: continue # alias the start and end indexes of the text token to paint token_start, token_end = token.span # 'click' and 'drag' to select the token text cursor.setPosition(token_start, QtGui.QTextCursor.MoveAnchor) cursor.setPosition(token_end, QtGui.QTextCursor.KeepAnchor) # configure the colors/style for this explicit token #highlight.setBackground(QtGui.QBrush(QtGui.QColor(TOKEN_COLORS[token.type]))) highlight.setForeground(QtGui.QBrush(TOKEN_COLORS[token.type])) cursor.setCharFormat(highlight) # # we are done painting all the parsed tokens. let's restore the user # cursor back to its original state so they are none-the-wiser # cursor.setPosition(cursor_position) cursor.setCharFormat(QtGui.QTextCharFormat()) self._line.setTextCursor(cursor) ################# UPDATES ENABLED ################# self._line.blockSignals(False) # done return def _color_invalid(self): """ Highlight the invalid (un-parsable) text. Please read through the _color_tokens() function for a more complete walkthrough of the text painting process. """ assert self._parser_error # the invalid text starts from the token that caused a parse error invalid_start = self._parser_error.error_index invalid_text = self.text[invalid_start:] # no invalid text? nothing to highlight I guess! if not invalid_text: return # alias the user cursor, and save its original (current) position cursor = self._line.textCursor() cursor_position = cursor.position() # setup the invalid text highlighter invalid_color = self._palette.shell_highlight_invalid highlight = QtGui.QTextCharFormat() highlight.setFontWeight(QtGui.QFont.Bold) highlight.setBackground(QtGui.QBrush(invalid_color)) self._line.blockSignals(True) ################# UPDATES DISABLED ################# # select the invalid text cursor.setPosition(invalid_start, QtGui.QTextCursor.MoveAnchor) cursor.setPosition(len(self.text), QtGui.QTextCursor.KeepAnchor) # insert a highlighted version of the invalid text cursor.setCharFormat(highlight) # reset the cursor position & style cursor.setPosition(cursor_position) cursor.setCharFormat(QtGui.QTextCharFormat()) self._line.setTextCursor(cursor) ################# UPDATES ENABLED ################# self._line.blockSignals(False) # done return #-------------------------------------------------------------------------- # General Highlighting #-------------------------------------------------------------------------- def _color_clear(self): """ Clear any existing text colors. """ self._color_text() def _color_text(self, color=None, start=0, end=0): """ Color shell text with the given color. """ # if no end was specified, apply the style till the end of input if end == 0: end = len(self.text) # alias the user cursor, and save its original (current) position cursor = self._line.textCursor() cursor_position = cursor.position() # setup a simple font coloring (or clearing) text format simple = QtGui.QTextCharFormat() if color: simple.setForeground(QtGui.QBrush(color)) self._line.blockSignals(True) ################# UPDATES DISABLED ################# # select the entire line cursor.setPosition(start, QtGui.QTextCursor.MoveAnchor) cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor) # set all the text to the simple format cursor.setCharFormat(simple) # reset the cursor position & style cursor.setPosition(cursor_position) self._line.setTextCursor(cursor) ################# UPDATES ENABLED ################# self._line.blockSignals(False) # done return #------------------------------------------------------------------------------ # Composing Line #------------------------------------------------------------------------------ class ComposingLine(QtWidgets.QPlainTextEdit): """ The textbox UI where user compositions are entered (typed). While this a QLineEdit may appear to be more appropriate for our 'Composing Shell', its support for syntax highlighting like features are completely absent. QPlainTextEdit has much better support for coloring or highlighting entered text, so we subclass from it and make a best effort attempt to make it appear and act like a QLineEdit 'shell' """ # # QLineEdit has a signal called 'returnPressed' which fires when the # user hits 'return' or 'enter'. This is a convenient signal, but # QPlainTextEdit does *not* have an equivalent. # # We define and fire this signal ourself for consistency and the same # conveniences as the one QLineEdit offers. # returnPressed = QtCore.pyqtSignal() def __init__(self, parent=None): super(ComposingLine, self).__init__(parent) self.setObjectName(self.__class__.__name__) # configure the widget for use self._ui_init() #-------------------------------------------------------------------------- # Initialization - UI #-------------------------------------------------------------------------- def _ui_init(self): """ Initialize UI elements. """ # initialize a monospace font to use with our widget(s) self._font = MonospaceFont() self._font.setPointSizeF(normalize_to_dpi(10)) self._font_metrics = QtGui.QFontMetricsF(self._font) self.setFont(self._font) # configure the QPlainTextEdit to appear and act as much like a # QLineEdit as possible (a single line text box) self.setWordWrapMode(QtGui.QTextOption.NoWrap) self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setTabChangesFocus(True) self.setMaximumBlockCount(1) # set the height of the textbox based on some arbitrary math :D LINE_PADDING = self.document().documentMargin()*2 line_height = self._font_metrics.height() + LINE_PADDING + 2 self.setFixedHeight(int(line_height)) #-------------------------------------------------------------------------- # QPlainTextEdit Overloads #-------------------------------------------------------------------------- def keyPressEvent(self, e): """ Overload of the key press event. """ # trap the return/enter key event if e.key() == QtCore.Qt.Key_Return or \ e.key() == QtCore.Qt.Key_Enter: # # fire our convenience signal notifying listeners that the user # pressed enter. this signal firing indicates the user is # probably trying to complete their query / input. # self.returnPressed.emit() # # now we must consume the keypress so it doesn't get passed on # to any other widgets/handlers/put in the text box # e.accept() # business as usual else: super(ComposingLine, self).keyPressEvent(e) def timerEvent(self, e): """ Stubbed out to prevent the QPlainTextEdit selection autoscroll. """ return