class QResizableScrollBar(QtGui.QScrollBar): resized = QtCore.Signal() def resizeEvent(self, event): super().resizeEvent(event) self.resized.emit()
class _DropEventEmitter(QtCore.QObject): """ Handle object drops on widget. """ signal = QtCore.Signal(object) def __init__(self, widget): QtCore.QObject.__init__(self, widget) self.widget = widget widget.setAcceptDrops(True) widget.installEventFilter(self) def eventFilter(self, source, event): """ Handle drop events on widget. """ typ = event.type() if typ == QtCore.QEvent.DragEnter: if hasattr(event.mimeData(), "instance"): # It is pymimedata and has instance data obj = event.mimeData().instance() if obj is not None: event.accept() return True elif typ == QtCore.QEvent.Drop: if hasattr(event.mimeData(), "instance"): # It is pymimedata and has instance data obj = event.mimeData().instance() if obj is not None: self.signal.emit(obj) event.accept() return True return QtCore.QObject.eventFilter(self, source, event)
class PythonWidget(HistoryConsoleWidget): """ A basic in-process Python interpreter. """ # Emitted when a command has been executed in the interpeter. executed = QtCore.Signal() #-------------------------------------------------------------------------- # 'object' interface #-------------------------------------------------------------------------- def __init__(self, parent=None): super(PythonWidget, self).__init__(parent) # PythonWidget attributes. self.locals = dict(__name__='__console__', __doc__=None) self.interpreter = InteractiveInterpreter(self.locals) # PythonWidget protected attributes. self._buffer = StringIO() self._bracket_matcher = BracketMatcher(self._control) self._call_tip_widget = CallTipWidget(self._control) self._completion_lexer = CompletionLexer(PythonLexer()) self._hidden = False self._highlighter = PythonWidgetHighlighter(self) self._last_refresh_time = 0 # file-like object attributes. self.encoding = sys.stdin.encoding # Configure the ConsoleWidget. self.tab_width = 4 self._set_continuation_prompt('... ') # Configure the CallTipWidget. self._call_tip_widget.setFont(self.font) self.font_changed.connect(self._call_tip_widget.setFont) # Connect signal handlers. document = self._control.document() document.contentsChange.connect(self._document_contents_change) # Display the banner and initial prompt. self.reset() #-------------------------------------------------------------------------- # file-like object interface #-------------------------------------------------------------------------- def flush(self): """ Flush the buffer by writing its contents to the screen. """ self._buffer.seek(0) text = self._buffer.getvalue() self._buffer.close() self._buffer = StringIO() self._append_plain_text(text) self._control.moveCursor(QtGui.QTextCursor.End) def readline(self, prompt=None): """ Read and return one line of input from the user. """ return self._readline(prompt) def write(self, text, refresh=True): """ Write text to the buffer, possibly flushing it if 'refresh' is set. """ if not self._hidden: self._buffer.write(text) if refresh: current_time = time() if current_time - self._last_refresh_time > 0.05: self.flush() self._last_refresh_time = current_time def writelines(self, lines, refresh=True): """ Write a list of lines to the buffer. """ for line in lines: self.write(line, refresh=refresh) #--------------------------------------------------------------------------- # 'ConsoleWidget' abstract interface #--------------------------------------------------------------------------- def _is_complete(self, source, interactive): """ Returns whether 'source' can be completely processed and a new prompt created. When triggered by an Enter/Return key press, 'interactive' is True; otherwise, it is False. """ if interactive: lines = source.splitlines() if len(lines) == 1: try: return compile_command(source) is not None except: # We'll let the interpeter handle the error. return True else: return lines[-1].strip() == '' else: return True def _execute(self, source, hidden): """ Execute 'source'. If 'hidden', do not show any output. See parent class :meth:`execute` docstring for full details. """ # Save the current std* and point them here old_stdin = sys.stdin old_stdout = sys.stdout old_stderr = sys.stderr sys.stdin = sys.stdout = sys.stderr = self # Run the source code in the interpeter self._hidden = hidden try: more = self.interpreter.runsource(source) finally: self._hidden = False # Restore std* unless the executed changed them if sys.stdin is self: sys.stdin = old_stdin if sys.stdout is self: sys.stdout = old_stdout if sys.stderr is self: sys.stderr = old_stderr self.executed.emit() self._show_interpreter_prompt() def _prompt_started_hook(self): """ Called immediately after a new prompt is displayed. """ if not self._reading: self._highlighter.highlighting_on = True def _prompt_finished_hook(self): """ Called immediately after a prompt is finished, i.e. when some input will be processed and a new prompt displayed. """ if not self._reading: self._highlighter.highlighting_on = False def _tab_pressed(self): """ Called when the tab key is pressed. Returns whether to continue processing the event. """ # Perform tab completion if: # 1) The cursor is in the input buffer. # 2) There is a non-whitespace character before the cursor. text = self._get_input_buffer_cursor_line() if text is None: return False complete = bool(text[:self._get_input_buffer_cursor_column()].strip()) if complete: self._complete() return not complete #--------------------------------------------------------------------------- # 'ConsoleWidget' protected interface #--------------------------------------------------------------------------- def _event_filter_console_keypress(self, event): """ Reimplemented for smart backspace. """ if event.key() == QtCore.Qt.Key_Backspace and \ not event.modifiers() & QtCore.Qt.AltModifier: # Smart backspace: remove four characters in one backspace if: # 1) everything left of the cursor is whitespace # 2) the four characters immediately left of the cursor are spaces col = self._get_input_buffer_cursor_column() cursor = self._control.textCursor() if col > 3 and not cursor.hasSelection(): text = self._get_input_buffer_cursor_line()[:col] if text.endswith(' ') and not text.strip(): cursor.movePosition(QtGui.QTextCursor.Left, QtGui.QTextCursor.KeepAnchor, 4) cursor.removeSelectedText() return True return super(PythonWidget, self)._event_filter_console_keypress(event) def _insert_continuation_prompt(self, cursor): """ Reimplemented for auto-indentation. """ super(PythonWidget, self)._insert_continuation_prompt(cursor) source = self.input_buffer space = 0 for c in source.splitlines()[-1]: if c == '\t': space += 4 elif c == ' ': space += 1 else: break if source.rstrip().endswith(':'): space += 4 cursor.insertText(' ' * space) #--------------------------------------------------------------------------- # 'PythonWidget' public interface #--------------------------------------------------------------------------- def execute_file(self, path, hidden=False): """ Attempts to execute file with 'path'. If 'hidden', no output is shown. """ self.execute('execfile("%s")' % path, hidden=hidden) def reset(self): """ Resets the widget to its initial state. Similar to ``clear``, but also re-writes the banner. """ self._reading = False self._highlighter.highlighting_on = False self._control.clear() self._append_plain_text(self._get_banner()) self._show_interpreter_prompt() #--------------------------------------------------------------------------- # 'PythonWidget' protected interface #--------------------------------------------------------------------------- def _call_tip(self): """ Shows a call tip, if appropriate, at the current cursor location. """ # Decide if it makes sense to show a call tip cursor = self._get_cursor() cursor.movePosition(QtGui.QTextCursor.Left) if cursor.document().characterAt(cursor.position()) != '(': return False context = self._get_context(cursor) if not context: return False # Look up the context and show a tip for it symbol, leftover = self._get_symbol_from_context(context) doc = getattr(symbol, '__doc__', None) if doc is not None and not leftover: self._call_tip_widget.show_call_info(doc=doc) return True return False def _complete(self): """ Performs completion at the current cursor location. """ context = self._get_context() if context: symbol, leftover = self._get_symbol_from_context(context) if len(leftover) == 1: leftover = leftover[0] if symbol is None: names = self.interpreter.locals.keys() names += __builtin__.__dict__.keys() else: names = dir(symbol) completions = [n for n in names if n.startswith(leftover)] if completions: cursor = self._get_cursor() cursor.movePosition(QtGui.QTextCursor.Left, n=len(context[-1])) self._complete_with_items(cursor, completions) def _get_banner(self): """ Gets a banner to display at the beginning of a session. """ banner = 'Python %s on %s\nType "help", "copyright", "credits" or ' \ '"license" for more information.' return banner % (sys.version, sys.platform) def _get_context(self, cursor=None): """ Gets the context for the specified cursor (or the current cursor if none is specified). """ if cursor is None: cursor = self._get_cursor() cursor.movePosition(QtGui.QTextCursor.StartOfBlock, QtGui.QTextCursor.KeepAnchor) text = cursor.selection().toPlainText() return self._completion_lexer.get_context(text) def _get_symbol_from_context(self, context): """ Find a python object in the interpeter namespace from a context (a list of names). """ context = map(str, context) if len(context) == 0: return None, context base_symbol_string = context[0] symbol = self.interpreter.locals.get(base_symbol_string, None) if symbol is None: symbol = __builtin__.__dict__.get(base_symbol_string, None) if symbol is None: return None, context context = context[1:] for i, name in enumerate(context): new_symbol = getattr(symbol, name, None) if new_symbol is None: return symbol, context[i:] else: symbol = new_symbol return symbol, [] def _show_interpreter_prompt(self): """ Shows a prompt for the interpreter. """ self.flush() self._show_prompt('>>> ') #------ Signal handlers ---------------------------------------------------- def _document_contents_change(self, position, removed, added): """ Called whenever the document's content changes. Display a call tip if appropriate. """ # Calculate where the cursor should be *after* the change: position += added document = self._control.document() if position == self._get_cursor().position(): self._call_tip()
class SplitTabWidget(QtGui.QSplitter): """ The SplitTabWidget class is a hierarchy of QSplitters the leaves of which are QTabWidgets. Any tab may be moved around with the hierarchy automatically extended and reduced as required. """ # Signals for WorkbenchWindowLayout to handle new_window_request = QtCore.Signal(QtCore.QPoint, QtGui.QWidget) tab_close_request = QtCore.Signal(QtGui.QWidget) tab_window_changed = QtCore.Signal(QtGui.QWidget) editor_has_focus = QtCore.Signal(QtGui.QWidget) focus_changed = QtCore.Signal(QtGui.QWidget, QtGui.QWidget) # The different hotspots of a QTabWidget. An non-negative value is a tab # index and the hotspot is to the left of it. tabTextChanged = QtCore.Signal(QtGui.QWidget, str) _HS_NONE = -1 _HS_AFTER_LAST_TAB = -2 _HS_NORTH = -3 _HS_SOUTH = -4 _HS_EAST = -5 _HS_WEST = -6 _HS_OUTSIDE = -7 def __init__(self, *args): """ Initialise the instance. """ QtGui.QSplitter.__init__(self, *args) self.clear() QtGui.QApplication.instance().focusChanged.connect(self._focus_changed) def clear(self): """ Restore the widget to its pristine state. """ w = None for i in range(self.count()): w = self.widget(i) w.hide() w.deleteLater() del w self._repeat_focus_changes = True self._rband = None self._selected_tab_widget = None self._selected_hotspot = self._HS_NONE self._current_tab_w = None self._current_tab_idx = -1 def saveState(self): """ Returns a Python object containing the saved state of the widget. Widgets are saved only by their object name. """ return self._save_qsplitter(self) def _save_qsplitter(self, qsplitter): # A splitter state is a tuple of the QSplitter state (as a string) and # the list of child states. sp_ch_states = [] # Save the children. for i in range(qsplitter.count()): ch = qsplitter.widget(i) if isinstance(ch, _TabWidget): # A tab widget state is a tuple of the current tab index and # the list of individual tab states. tab_states = [] for t in range(ch.count()): # A tab state is a tuple of the widget's object name and # the title. name = str(ch.widget(t).objectName()) title = str(ch.tabText(t)) tab_states.append((name, title)) ch_state = (ch.currentIndex(), tab_states) else: # Recurse down the tree of splitters. ch_state = self._save_qsplitter(ch) sp_ch_states.append(ch_state) return (QtGui.QSplitter.saveState(qsplitter).data(), sp_ch_states) def restoreState(self, state, factory): """ Restore the contents from the given state (returned by a previous call to saveState()). factory is a callable that is passed the object name of the widget that is in the state and needs to be restored. The callable returns the restored widget. """ # Ensure we are not restoring to a non-empty widget. assert self.count() == 0 self._restore_qsplitter(state, factory, self) def _restore_qsplitter(self, state, factory, qsplitter): sp_qstate, sp_ch_states = state # Go through each child state which will consist of a tuple of two # objects. We use the type of the first to determine if the child is a # tab widget or another splitter. for ch_state in sp_ch_states: if isinstance(ch_state[0], int): current_idx, tabs = ch_state new_tab = _TabWidget(self) # Go through each tab and use the factory to restore the page. for name, title in tabs: page = factory(name) if page is not None: new_tab.addTab(page, title) # Only add the new tab widget if it is used. if new_tab.count() > 0: qsplitter.addWidget(new_tab) # Set the correct tab as the current one. new_tab.setCurrentIndex(current_idx) else: del new_tab else: new_qsp = QtGui.QSplitter() # Recurse down the tree of splitters. self._restore_qsplitter(ch_state, factory, new_qsp) # Only add the new splitter if it is used. if new_qsp.count() > 0: qsplitter.addWidget(new_qsp) else: del new_qsp # Restore the QSplitter state (being careful to get the right # implementation). QtGui.QSplitter.restoreState(qsplitter, sp_qstate) def addTab(self, w, text): """ Add a new tab to the main tab widget. """ # Find the first tab widget going down the left of the hierarchy. This # will be the one in the top left corner. if self.count() > 0: ch = self.widget(0) while not isinstance(ch, _TabWidget): assert isinstance(ch, QtGui.QSplitter) ch = ch.widget(0) else: # There is no tab widget so create one. ch = _TabWidget(self) self.addWidget(ch) idx = ch.insertTab(self._current_tab_idx + 1, w, text) # If the tab has been added to the current tab widget then make it the # current tab. if ch is not self._current_tab_w: self._set_current_tab(ch, idx) ch.tabBar().setFocus() def _close_tab_request(self, w): """ A close button was clicked in one of out _TabWidgets """ self.tab_close_request.emit(w) def setCurrentWidget(self, w): """ Make the given widget current. """ tw, tidx = self._tab_widget(w) if tw is not None: self._set_current_tab(tw, tidx) def setActiveIcon(self, w, icon=None): """ Set the active icon on a widget. """ tw, tidx = self._tab_widget(w) if tw is not None: if icon is None: icon = tw.active_icon() tw.setTabIcon(tidx, icon) def setTabTextColor(self, w, color=None): """ Set the tab text color on a particular widget w """ tw, tidx = self._tab_widget(w) if tw is not None: if color is None: # null color reverts to foreground role color color = QtGui.QColor() tw.tabBar().setTabTextColor(tidx, color) def setWidgetTitle(self, w, title): """ Set the title for the given widget. """ tw, idx = self._tab_widget(w) if tw is not None: tw.setTabText(idx, title) def _tab_widget(self, w): """ Return the tab widget and index containing the given widget. """ for tw in self.findChildren(_TabWidget, None): idx = tw.indexOf(w) if idx >= 0: return (tw, idx) return (None, None) def _set_current_tab(self, tw, tidx): """ Set the new current tab. """ # Handle the trivial case. if self._current_tab_w is tw and self._current_tab_idx == tidx: return if tw is not None: tw.setCurrentIndex(tidx) # Save the new current widget. self._current_tab_w = tw self._current_tab_idx = tidx def _set_focus(self): """ Set the focus to an appropriate widget in the current tab. """ # Only try and change the focus if the current focus isn't already a # child of the widget. w = self._current_tab_w.widget(self._current_tab_idx) fw = self.window().focusWidget() if fw is not None and not w.isAncestorOf(fw): # Find a widget to focus using the same method as # QStackedLayout::setCurrentIndex(). First try the last widget # with the focus. nfw = w.focusWidget() if nfw is None: # Next, try the first child widget in the focus chain. nfw = fw.nextInFocusChain() while nfw is not fw: if nfw.focusPolicy() & QtCore.Qt.TabFocus and \ nfw.focusProxy() is None and \ nfw.isVisibleTo(w) and \ nfw.isEnabled() and \ w.isAncestorOf(nfw): break nfw = nfw.nextInFocusChain() else: # Fallback to the tab page widget. nfw = w nfw.setFocus() def _focus_changed(self, old, new): """ Handle a change in focus that affects the current tab. """ # It is possible for the C++ layer of this object to be deleted between # the time when the focus change signal is emitted and time when the # slots are dispatched by the Qt event loop. This may be a bug in PyQt4. if qt_api == 'pyqt': import sip if sip.isdeleted(self): return if self._repeat_focus_changes: self.focus_changed.emit(old, new) if new is None: return elif isinstance(new, _DragableTabBar): ntw = new.parent() ntidx = ntw.currentIndex() else: ntw, ntidx = self._tab_widget_of(new) if ntw is not None: self._set_current_tab(ntw, ntidx) # See if the widget that has lost the focus is ours. otw, _ = self._tab_widget_of(old) if otw is not None or ntw is not None: if ntw is None: nw = None else: nw = ntw.widget(ntidx) self.editor_has_focus.emit(nw) def _tab_widget_of(self, target): """ Return the tab widget and index of the widget that contains the given widget. """ for tw in self.findChildren(_TabWidget, None): for tidx in range(tw.count()): w = tw.widget(tidx) if w is not None and w.isAncestorOf(target): return (tw, tidx) return (None, None) def _move_left(self, tw, tidx): """ Move the current tab to the one logically to the left. """ tidx -= 1 if tidx < 0: # Find the tab widget logically to the left. twlist = self.findChildren(_TabWidget, None) i = twlist.index(tw) i -= 1 if i < 0: i = len(twlist) - 1 tw = twlist[i] # Move the to right most tab. tidx = tw.count() - 1 self._set_current_tab(tw, tidx) tw.setFocus() def _move_right(self, tw, tidx): """ Move the current tab to the one logically to the right. """ tidx += 1 if tidx >= tw.count(): # Find the tab widget logically to the right. twlist = self.findChildren(_TabWidget, None) i = twlist.index(tw) i += 1 if i >= len(twlist): i = 0 tw = twlist[i] # Move the to left most tab. tidx = 0 self._set_current_tab(tw, tidx) tw.setFocus() def _select(self, pos): tw, hs, hs_geom = self._hotspot(pos) # See if the hotspot has changed. if self._selected_tab_widget is not tw or self._selected_hotspot != hs: if self._selected_tab_widget is not None: self._rband.hide() if tw is not None and hs != self._HS_NONE: if self._rband: self._rband.deleteLater() position = QtCore.QPoint(*hs_geom[0:2]) window = tw.window() self._rband = QtGui.QRubberBand(QtGui.QRubberBand.Rectangle, window) self._rband.move(window.mapFromGlobal(position)) self._rband.resize(*hs_geom[2:4]) self._rband.show() self._selected_tab_widget = tw self._selected_hotspot = hs def _drop(self, pos, stab_w, stab): self._rband.hide() # Get the destination locations. dtab_w = self._selected_tab_widget dhs = self._selected_hotspot if dhs == self._HS_NONE: return elif dhs != self._HS_OUTSIDE: dsplit_w = dtab_w.parent() while not isinstance(dsplit_w, SplitTabWidget): dsplit_w = dsplit_w.parent() self._selected_tab_widget = None self._selected_hotspot = self._HS_NONE # See if the tab is being moved to a new window. if dhs == self._HS_OUTSIDE: # Disable tab tear-out for now. It works, but this is something that # should be turned on manually. We need an interface for this. #ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab(stab_w, stab) #self.new_window_request.emit(pos, twidg) return # See if the tab is being moved to an existing tab widget. if dhs >= 0 or dhs == self._HS_AFTER_LAST_TAB: # Make sure it really is being moved. if stab_w is dtab_w: if stab == dhs: return if dhs == self._HS_AFTER_LAST_TAB and stab == stab_w.count( ) - 1: return QtGui.QApplication.instance().blockSignals(True) ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab( stab_w, stab) if dhs == self._HS_AFTER_LAST_TAB: idx = dtab_w.addTab(twidg, ticon, ttext) dtab_w.tabBar().setTabTextColor(idx, ttextcolor) elif dtab_w is stab_w: # Adjust the index if necessary in case the removal of the tab # from its old position has skewed things. dst = dhs if dhs > stab: dst -= 1 idx = dtab_w.insertTab(dst, twidg, ticon, ttext) dtab_w.tabBar().setTabTextColor(idx, ttextcolor) else: idx = dtab_w.insertTab(dhs, twidg, ticon, ttext) dtab_w.tabBar().setTabTextColor(idx, ttextcolor) if (tbuttn): dtab_w.show_button(idx) dsplit_w._set_current_tab(dtab_w, idx) else: # Ignore drops to the same tab widget when it only has one tab. if stab_w is dtab_w and stab_w.count() == 1: return QtGui.QApplication.instance().blockSignals(True) # Remove the tab from its current tab widget and create a new one # for it. ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab( stab_w, stab) new_tw = _TabWidget(dsplit_w) idx = new_tw.addTab(twidg, ticon, ttext) new_tw.tabBar().setTabTextColor(0, ttextcolor) if tbuttn: new_tw.show_button(idx) # Get the splitter containing the destination tab widget. dspl = dtab_w.parent() dspl_idx = dspl.indexOf(dtab_w) if dhs in (self._HS_NORTH, self._HS_SOUTH): dspl, dspl_idx = dsplit_w._horizontal_split( dspl, dspl_idx, dhs) else: dspl, dspl_idx = dsplit_w._vertical_split(dspl, dspl_idx, dhs) # Add the new tab widget in the right place. dspl.insertWidget(dspl_idx, new_tw) dsplit_w._set_current_tab(new_tw, 0) dsplit_w._set_focus() # Signal that the tab's SplitTabWidget has changed, if necessary. if dsplit_w != self: self.tab_window_changed.emit(twidg) QtGui.QApplication.instance().blockSignals(False) def _horizontal_split(self, spl, idx, hs): """ Returns a tuple of the splitter and index where the new tab widget should be put. """ if spl.orientation() == QtCore.Qt.Vertical: if hs == self._HS_SOUTH: idx += 1 elif spl is self and spl.count() == 1: # The splitter is the root and only has one child so we can just # change its orientation. spl.setOrientation(QtCore.Qt.Vertical) if hs == self._HS_SOUTH: idx = -1 else: new_spl = QtGui.QSplitter(QtCore.Qt.Vertical) new_spl.addWidget(spl.widget(idx)) spl.insertWidget(idx, new_spl) if hs == self._HS_SOUTH: idx = -1 else: idx = 0 spl = new_spl return (spl, idx) def _vertical_split(self, spl, idx, hs): """ Returns a tuple of the splitter and index where the new tab widget should be put. """ if spl.orientation() == QtCore.Qt.Horizontal: if hs == self._HS_EAST: idx += 1 elif spl is self and spl.count() == 1: # The splitter is the root and only has one child so we can just # change its orientation. spl.setOrientation(QtCore.Qt.Horizontal) if hs == self._HS_EAST: idx = -1 else: new_spl = QtGui.QSplitter(QtCore.Qt.Horizontal) new_spl.addWidget(spl.widget(idx)) spl.insertWidget(idx, new_spl) if hs == self._HS_EAST: idx = -1 else: idx = 0 spl = new_spl return (spl, idx) def _remove_tab(self, tab_w, tab): """ Remove a tab from a tab widget and return a tuple of the icon, label text and the widget so that it can be recreated. """ icon = tab_w.tabIcon(tab) text = tab_w.tabText(tab) text_color = tab_w.tabBar().tabTextColor(tab) button = tab_w.tabBar().tabButton(tab, QtGui.QTabBar.LeftSide) w = tab_w.widget(tab) tab_w.removeTab(tab) return (icon, text, text_color, button, w) def _hotspot(self, pos): """ Return a tuple of the tab widget, hotspot and hostspot geometry (as a tuple) at the given position. """ global_pos = self.mapToGlobal(pos) miss = (None, self._HS_NONE, None) # Get the bounding rect of the cloned QTbarBar. top_widget = QtGui.QApplication.instance().topLevelAt(global_pos) if isinstance(top_widget, QtGui.QTabBar): cloned_rect = top_widget.frameGeometry() else: cloned_rect = None # Determine which visible SplitTabWidget, if any, is under the cursor # (compensating for the cloned QTabBar that may be rendered over it). split_widget = None for top_widget in QtGui.QApplication.instance().topLevelWidgets(): for split_widget in top_widget.findChildren(SplitTabWidget, None): visible_region = split_widget.visibleRegion() widget_pos = split_widget.mapFromGlobal(global_pos) if cloned_rect and split_widget.geometry().contains( widget_pos): visible_rect = visible_region.boundingRect() widget_rect = QtCore.QRect( split_widget.mapFromGlobal(cloned_rect.topLeft()), split_widget.mapFromGlobal(cloned_rect.bottomRight())) if not visible_rect.intersected(widget_rect).isEmpty(): break elif visible_region.contains(widget_pos): break else: split_widget = None if split_widget: break # Handle a drag outside of any split tab widget. if not split_widget: if self.window().frameGeometry().contains(global_pos): return miss else: return (None, self._HS_OUTSIDE, None) # Go through each tab widget. pos = split_widget.mapFromGlobal(global_pos) for tw in split_widget.findChildren(_TabWidget, None): if tw.geometry().contains(tw.parent().mapFrom(split_widget, pos)): break else: return miss # See if the hotspot is in the widget area. widg = tw.currentWidget() if widg is not None: # Get the widget's position relative to its parent. wpos = widg.parent().mapFrom(split_widget, pos) if widg.geometry().contains(wpos): # Get the position of the widget relative to itself (ie. the # top left corner is (0, 0)). p = widg.mapFromParent(wpos) x = p.x() y = p.y() h = widg.height() w = widg.width() # Get the global position of the widget. gpos = widg.mapToGlobal(widg.pos()) gx = gpos.x() gy = gpos.y() # The corners of the widget belong to the north and south # sides. if y < h / 4: return (tw, self._HS_NORTH, (gx, gy, w, h / 4)) if y >= (3 * h) / 4: return (tw, self._HS_SOUTH, (gx, gy + (3 * h) / 4, w, h / 4)) if x < w / 4: return (tw, self._HS_WEST, (gx, gy, w / 4, h)) if x >= (3 * w) / 4: return (tw, self._HS_EAST, (gx + (3 * w) / 4, gy, w / 4, h)) return miss # See if the hotspot is in the tab area. tpos = tw.mapFrom(split_widget, pos) tab_bar = tw.tabBar() top_bottom = tw.tabPosition() in (QtGui.QTabWidget.North, QtGui.QTabWidget.South) for i in range(tw.count()): rect = tab_bar.tabRect(i) if rect.contains(tpos): w = rect.width() h = rect.height() # Get the global position. gpos = tab_bar.mapToGlobal(rect.topLeft()) gx = gpos.x() gy = gpos.y() if top_bottom: off = pos.x() - rect.x() ext = w gx -= w / 2 else: off = pos.y() - rect.y() ext = h gy -= h / 2 # See if it is in the left (or top) half or the right (or # bottom) half. if off < ext / 2: return (tw, i, (gx, gy, w, h)) if top_bottom: gx += w else: gy += h if i + 1 == tw.count(): return (tw, self._HS_AFTER_LAST_TAB, (gx, gy, w, h)) return (tw, i + 1, (gx, gy, w, h)) else: rect = tab_bar.rect() if rect.contains(tpos): gpos = tab_bar.mapToGlobal(rect.topLeft()) gx = gpos.x() gy = gpos.y() w = rect.width() h = rect.height() if top_bottom: tab_widths = sum( tab_bar.tabRect(i).width() for i in range(tab_bar.count())) w -= tab_widths gx += tab_widths else: tab_heights = sum( tab_bar.tabRect(i).height() for i in range(tab_bar.count())) h -= tab_heights gy -= tab_heights return (tw, self._HS_AFTER_LAST_TAB, (gx, gy, w, h)) return miss
class CodeWidget(QtGui.QPlainTextEdit): """ A widget for viewing and editing code. """ ########################################################################### # CodeWidget interface ########################################################################### focus_lost = QtCore.Signal() def __init__(self, parent, should_highlight_current_line=True, font=None, lexer=None): super(CodeWidget, self).__init__(parent) self.highlighter = PygmentsHighlighter(self.document(), lexer) self.line_number_widget = LineNumberWidget(self) self.status_widget = StatusGutterWidget(self) if font is None: # Set a decent fixed width font for this platform. font = QtGui.QFont() if sys.platform == 'win32': # Prefer Consolas, but fall back to Courier if necessary. font.setFamily('Consolas') if not font.exactMatch(): font.setFamily('Courier') elif sys.platform == 'darwin': font.setFamily('Monaco') else: font.setFamily('Monospace') font.setStyleHint(QtGui.QFont.TypeWriter) self.set_font(font) # Whether we should highlight the current line or not. self.should_highlight_current_line = should_highlight_current_line # What that highlight color should be. self.line_highlight_color = QtGui.QColor(QtCore.Qt.yellow).lighter(160) # Auto-indentation behavior self.auto_indent = True self.smart_backspace = True # Tab settings self.tabs_as_spaces = True self.tab_width = 4 self.indent_character = ':' self.comment_character = '#' # Set up gutter widget and current line highlighting self.blockCountChanged.connect(self.update_line_number_width) self.updateRequest.connect(self.update_line_numbers) self.cursorPositionChanged.connect(self.highlight_current_line) self.update_line_number_width() self.highlight_current_line() # Don't wrap text self.setLineWrapMode(QtGui.QPlainTextEdit.NoWrap) # Key bindings self.indent_key = QtGui.QKeySequence(QtCore.Qt.Key_Tab) self.unindent_key = QtGui.QKeySequence(QtCore.Qt.SHIFT + QtCore.Qt.Key_Backtab) self.comment_key = QtGui.QKeySequence(QtCore.Qt.CTRL + QtCore.Qt.Key_Slash) self.backspace_key = QtGui.QKeySequence(QtCore.Qt.Key_Backspace) def lines(self): """ Return the number of lines. """ return self.blockCount() def set_line_column(self, line, column): """ Move the cursor to a particular line/column number. These line and column numbers are 1-indexed. """ # Allow the caller to ignore either line or column by passing None. line0, col0 = self.get_line_column() if line is None: line = line0 if column is None: column = col0 line -= 1 column -= 1 block = self.document().findBlockByLineNumber(line) line_start = block.position() position = line_start + column cursor = self.textCursor() cursor.setPosition(position) self.setTextCursor(cursor) def get_line_column(self): """ Get the current line and column numbers. These line and column numbers are 1-indexed. """ cursor = self.textCursor() pos = cursor.position() line = cursor.blockNumber() + 1 line_start = cursor.block().position() column = pos - line_start + 1 return line, column def get_selected_text(self): """ Return the currently selected text. """ return unicode(self.textCursor().selectedText()) def set_font(self, font): """ Set the new QFont. """ self.document().setDefaultFont(font) self.line_number_widget.set_font(font) self.update_line_number_width() def update_line_number_width(self, nblocks=0): """ Update the width of the line number widget. """ left = 0 if not self.line_number_widget.isHidden(): left = self.line_number_widget.digits_width() self.setViewportMargins(left, 0, 0, 0) def update_line_numbers(self, rect, dy): """ Update the line numbers. """ if dy: self.line_number_widget.scroll(0, dy) self.line_number_widget.update(0, rect.y(), self.line_number_widget.width(), rect.height()) if rect.contains(self.viewport().rect()): self.update_line_number_width() def set_info_lines(self, info_lines): self.status_widget.info_lines = info_lines self.status_widget.update() def set_warn_lines(self, warn_lines): self.status_widget.warn_lines = warn_lines self.status_widget.update() def set_error_lines(self, error_lines): self.status_widget.error_lines = error_lines self.status_widget.update() def highlight_current_line(self): """ Highlight the line with the cursor. """ if self.should_highlight_current_line: selection = QtGui.QTextEdit.ExtraSelection() selection.format.setBackground(self.line_highlight_color) selection.format.setProperty(QtGui.QTextFormat.FullWidthSelection, True) selection.cursor = self.textCursor() selection.cursor.clearSelection() self.setExtraSelections([selection]) def autoindent_newline(self): tab = '\t' if self.tabs_as_spaces: tab = ' ' * self.tab_width cursor = self.textCursor() text = cursor.block().text() trimmed = text.rstrip() current_indent_pos = self._get_indent_position(text) cursor.beginEditBlock() # Create the new line. There is no need to move to the new block, as # the insertBlock will do that automatically cursor.insertBlock() # Remove any leading whitespace from the current line after = cursor.block().text() trimmed_after = after.rstrip() pos = after.index(trimmed_after) for i in range(pos): cursor.deleteChar() if self.indent_character and trimmed.endswith(self.indent_character): # indent one level indent = text[:current_indent_pos] + tab else: # indent to the same level indent = text[:current_indent_pos] cursor.insertText(indent) cursor.endEditBlock() self.ensureCursorVisible() def block_indent(self): cursor = self.textCursor() if not cursor.hasSelection(): # Insert a tabulator self.line_indent(cursor) else: # Indent every selected line sel_blocks = self._get_selected_blocks() cursor.clearSelection() cursor.beginEditBlock() for block in sel_blocks: cursor.setPosition(block.position()) self.line_indent(cursor) cursor.endEditBlock() self._show_selected_blocks(sel_blocks) def block_unindent(self): cursor = self.textCursor() if not cursor.hasSelection(): # Unindent current line position = cursor.position() cursor.beginEditBlock() removed = self.line_unindent(cursor) position = max(position - removed, 0) cursor.endEditBlock() cursor.setPosition(position) self.setTextCursor(cursor) else: # Unindent every selected line sel_blocks = self._get_selected_blocks() cursor.clearSelection() cursor.beginEditBlock() for block in sel_blocks: cursor.setPosition(block.position()) self.line_unindent(cursor) cursor.endEditBlock() self._show_selected_blocks(sel_blocks) def block_comment(self): """the comment char will be placed at the first non-whitespace char of the first line. For example: if foo: bar will be commented as: #if foo: # bar """ cursor = self.textCursor() if not cursor.hasSelection(): text = cursor.block().text() current_indent_pos = self._get_indent_position(text) if text[current_indent_pos] == self.comment_character: self.line_uncomment(cursor, current_indent_pos) else: self.line_comment(cursor, current_indent_pos) else: sel_blocks = self._get_selected_blocks() text = sel_blocks[0].text() indent_pos = self._get_indent_position(text) comment = True for block in sel_blocks: text = block.text() if len(text) > indent_pos and \ text[indent_pos] == self.comment_character: # Already commented. comment = False break cursor.clearSelection() cursor.beginEditBlock() for block in sel_blocks: cursor.setPosition(block.position()) if comment: if block.length() < indent_pos: cursor.insertText(' ' * indent_pos) self.line_comment(cursor, indent_pos) else: self.line_uncomment(cursor, indent_pos) cursor.endEditBlock() self._show_selected_blocks(sel_blocks) def line_comment(self, cursor, position): cursor.movePosition(QtGui.QTextCursor.StartOfBlock) cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.MoveAnchor, position) cursor.insertText(self.comment_character) def line_uncomment(self, cursor, position=0): cursor.movePosition(QtGui.QTextCursor.StartOfBlock) text = cursor.block().text() new_text = text[:position] + text[position + 1:] cursor.movePosition(QtGui.QTextCursor.EndOfBlock, QtGui.QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.insertText(new_text) def line_indent(self, cursor): tab = '\t' if self.tabs_as_spaces: tab = ' ' cursor.insertText(tab) def line_unindent(self, cursor): """ Unindents the cursor's line. Returns the number of characters removed. """ tab = '\t' if self.tabs_as_spaces: tab = ' ' cursor.movePosition(QtGui.QTextCursor.StartOfBlock) if cursor.block().text().startswith(tab): new_text = cursor.block().text()[len(tab):] cursor.movePosition(QtGui.QTextCursor.EndOfBlock, QtGui.QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.insertText(new_text) return len(tab) else: return 0 def word_under_cursor(self): """ Return the word under the cursor. """ cursor = self.textCursor() cursor.select(QtGui.QTextCursor.WordUnderCursor) return unicode(cursor.selectedText()) ########################################################################### # QWidget interface ########################################################################### # FIXME: This is a quick hack to be able to access the keyPressEvent # from the rest editor. This should be changed to work within the traits # framework. def keyPressEvent_action(self, event): pass def keyPressEvent(self, event): if self.isReadOnly(): return super(CodeWidget, self).keyPressEvent(event) key_sequence = QtGui.QKeySequence(event.key() + int(event.modifiers())) self.keyPressEvent_action(event) # FIXME: see above # If the cursor is in the middle of the first line, pressing the "up" # key causes the cursor to go to the start of the first line, i.e. the # beginning of the document. Likewise, if the cursor is somewhere in the # last line, the "down" key causes it to go to the end. cursor = self.textCursor() if key_sequence.matches(QtGui.QKeySequence(QtCore.Qt.Key_Up)): cursor.movePosition(QtGui.QTextCursor.StartOfLine) if cursor.atStart(): self.setTextCursor(cursor) event.accept() elif key_sequence.matches(QtGui.QKeySequence(QtCore.Qt.Key_Down)): cursor.movePosition(QtGui.QTextCursor.EndOfLine) if cursor.atEnd(): self.setTextCursor(cursor) event.accept() elif self.auto_indent and \ key_sequence.matches(QtGui.QKeySequence(QtCore.Qt.Key_Return)): event.accept() return self.autoindent_newline() elif key_sequence.matches(self.indent_key): event.accept() return self.block_indent() elif key_sequence.matches(self.unindent_key): event.accept() return self.block_unindent() elif key_sequence.matches(self.comment_key): event.accept() return self.block_comment() elif self.auto_indent and self.smart_backspace and \ key_sequence.matches(self.backspace_key) and \ self._backspace_should_unindent(): event.accept() return self.block_unindent() return super(CodeWidget, self).keyPressEvent(event) def resizeEvent(self, event): QtGui.QPlainTextEdit.resizeEvent(self, event) contents = self.contentsRect() self.line_number_widget.setGeometry( QtCore.QRect(contents.left(), contents.top(), self.line_number_widget.digits_width(), contents.height())) # use the viewport width to determine the right edge. This allows for # the propper placement w/ and w/o the scrollbar right_pos = self.viewport().width() + self.line_number_widget.width() + 1\ - self.status_widget.sizeHint().width() self.status_widget.setGeometry( QtCore.QRect(right_pos, contents.top(), self.status_widget.sizeHint().width(), contents.height())) def focusOutEvent(self, event): QtGui.QPlainTextEdit.focusOutEvent(self, event) self.focus_lost.emit() def sizeHint(self): # Suggest a size that is 80 characters wide and 40 lines tall. style = self.style() opt = QtGui.QStyleOptionHeader() font_metrics = QtGui.QFontMetrics(self.document().defaultFont()) width = font_metrics.width(' ') * 80 width += self.line_number_widget.sizeHint().width() width += self.status_widget.sizeHint().width() width += style.pixelMetric(QtGui.QStyle.PM_ScrollBarExtent, opt, self) height = font_metrics.height() * 40 return QtCore.QSize(width, height) ########################################################################### # Private methods ########################################################################### def _get_indent_position(self, line): trimmed = line.rstrip() if len(trimmed) != 0: return line.index(trimmed) else: # if line is all spaces, treat it as the indent position return len(line) def _show_selected_blocks(self, selected_blocks): """ Assumes contiguous blocks """ cursor = self.textCursor() cursor.clearSelection() cursor.setPosition(selected_blocks[0].position()) cursor.movePosition(QtGui.QTextCursor.StartOfBlock) cursor.movePosition(QtGui.QTextCursor.NextBlock, QtGui.QTextCursor.KeepAnchor, len(selected_blocks)) cursor.movePosition(QtGui.QTextCursor.EndOfBlock, QtGui.QTextCursor.KeepAnchor) self.setTextCursor(cursor) def _get_selected_blocks(self): cursor = self.textCursor() if cursor.position() > cursor.anchor(): move_op = QtGui.QTextCursor.PreviousBlock start_pos = cursor.anchor() end_pos = cursor.position() else: move_op = QtGui.QTextCursor.NextBlock start_pos = cursor.position() end_pos = cursor.anchor() cursor.setPosition(start_pos) cursor.movePosition(QtGui.QTextCursor.StartOfBlock) blocks = [cursor.block()] while cursor.movePosition(QtGui.QTextCursor.NextBlock): block = cursor.block() if block.position() < end_pos: blocks.append(block) return blocks def _backspace_should_unindent(self): cursor = self.textCursor() # Don't unindent if we have a selection. if cursor.hasSelection(): return False column = cursor.columnNumber() # Don't unindent if we are at the beggining of the line if column < self.tab_width: return False else: # Unindent if we are at the indent position return column == self._get_indent_position(cursor.block().text())
class myCodeWidget(CodeWidget): dclicked = QtCore.Signal((str, )) modified_select = QtCore.Signal((str, )) alt_select = QtCore.Signal((str, int, int)) _current_pos = None gotos = ['gosub'] popup = None def __init__(self, *args, **kw): super(myCodeWidget, self).__init__(*args, **kw) self.setMouseTracking(True) def keyPressEvent(self, event): super(myCodeWidget, self).keyPressEvent(event) if event.modifiers() & Qt.ControlModifier: QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) # self.setMouseTracking(True) def keyReleaseEvent(self, event): super(myCodeWidget, self).keyReleaseEvent(event) # self.setMouseTracking(False) QApplication.restoreOverrideCursor() if self.popup: self.popup.close() self.popup = None def clear_selected(self): # self.setMouseTracking(False) self.clear_underline() QApplication.restoreOverrideCursor() def clear_underline(self): cursor = self.textCursor() cursor.select(QTextCursor.Document) fmt = cursor.charFormat() fmt.setFontUnderline(False) cursor.beginEditBlock() cursor.setCharFormat(fmt) cursor.endEditBlock() def replace_selection(self, txt): cursor = self.textCursor() # # QtGui.QTextCursor.StartOfLine, QtGui.QTextCursor.KeepAnchor, txt.count('\n')) cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(txt) cursor.endEditBlock() # cursor.movePosition( # QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor,len(txt)) # cursor.movePosition( # QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor,len(txt)) self.setTextCursor(cursor) def mouseMoveEvent(self, event): if event.modifiers() & Qt.ControlModifier: self.clear_underline() cursor, line = self._get_line_cursor(event.pos()) for goto in self.gotos: if line.strip().startswith(goto): fmt = QTextCharFormat() fmt.setFontUnderline(True) fmt.setUnderlineStyle(QTextCharFormat.WaveUnderline) fmt.setUnderlineColor(QtGui.QColor('blue')) # cursor.clearSelection() cursor.select(QTextCursor.BlockUnderCursor) cursor.beginEditBlock() cursor.setCharFormat(fmt) cursor.endEditBlock() break super(myCodeWidget, self).mouseMoveEvent(event) # def mouseReleaseEvent(self, event): # if event.modifiers() & Qt.AltModifier: # print 'popup', self.popup # if self.popup: # self.popup.close() # self.popup = None def mousePressEvent(self, event): if event.modifiers() & Qt.ControlModifier: # on Mac OSX "command" cursor, line = self._get_line_cursor(event.pos()) self.modified_select.emit(line.strip()) self.clear_selected() elif event.modifiers() & Qt.AltModifier: # On Mac OSX "option" cursor, line = self._get_line_cursor(event.pos()) pt = self.mapToGlobal(event.pos()) self.alt_select.emit(line.strip(), pt.x(), pt.y()) self._current_pos = None super(myCodeWidget, self).mousePressEvent(event) def get_current_line(self): cursor = self.textCursor() cursor.select(QTextCursor.LineUnderCursor) line = cursor.selectedText() return line.strip() def _get_line_cursor(self, pos): cursor = self.cursorForPosition(pos) cursor.select(QTextCursor.LineUnderCursor) line = cursor.selectedText() return cursor, line def mouseDoubleClickEvent(self, event): self.clear_selected() self._current_pos = event.pos() cursor, line = self._get_line_cursor(self._current_pos) self.dclicked.emit(line.strip()) def replace_command(self, cmd): if self._current_pos: cursor, line = self._get_line_cursor(self._current_pos) lead = len(line) - len(line.lstrip()) cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText('{}{}'.format(' ' * lead, cmd)) cursor.endEditBlock()