Files

1087 lines
36 KiB
Python

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