/******************************************************************************* * Copyright (c) 2005, 2007 IBM Corporation and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * *******************************************************************************/ package com.aptana.interactive_console.console.ui.internal; import java.lang.reflect.Method; import java.util.List; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.DocumentEvent; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentListener; import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.text.contentassist.ContentAssistEvent; import org.eclipse.jface.text.contentassist.ICompletionListener; import org.eclipse.jface.text.contentassist.ICompletionProposal; import org.eclipse.jface.text.contentassist.IContentAssistantExtension2; import org.eclipse.jface.text.source.SourceViewerConfiguration; import org.eclipse.jface.util.LocalSelectionTransfer; import org.eclipse.jface.viewers.ISelection; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.ExtendedModifyEvent; import org.eclipse.swt.custom.ExtendedModifyListener; import org.eclipse.swt.custom.ST; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.custom.VerifyKeyListener; import org.eclipse.swt.dnd.DND; import org.eclipse.swt.dnd.DragSource; import org.eclipse.swt.dnd.DragSourceEvent; import org.eclipse.swt.dnd.DragSourceListener; import org.eclipse.swt.dnd.DropTarget; import org.eclipse.swt.dnd.DropTargetEvent; import org.eclipse.swt.dnd.DropTargetListener; import org.eclipse.swt.dnd.TextTransfer; import org.eclipse.swt.dnd.Transfer; import org.eclipse.swt.events.FocusEvent; import org.eclipse.swt.events.FocusListener; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.KeyListener; import org.eclipse.swt.events.TraverseEvent; import org.eclipse.swt.events.TraverseListener; import org.eclipse.swt.events.VerifyEvent; import org.eclipse.swt.events.VerifyListener; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Display; import org.eclipse.ui.console.TextConsoleViewer; import com.aptana.interactive_console.console.ScriptConsoleHistory; import com.aptana.interactive_console.console.codegen.IScriptConsoleCodeGenerator; import com.aptana.interactive_console.console.codegen.PythonSnippetUtils; import com.aptana.interactive_console.console.codegen.SafeScriptConsoleCodeGenerator; import com.aptana.interactive_console.console.ui.IConsoleStyleProvider; import com.aptana.interactive_console.console.ui.IScriptConsoleViewer; import com.aptana.interactive_console.console.ui.ScriptConsole; import com.aptana.interactive_console.console.ui.internal.actions.AbstractHandleBackspaceAction; import com.aptana.interactive_console.console.ui.internal.actions.HandleDeletePreviousWord; import com.aptana.interactive_console.console.ui.internal.actions.HandleLineStartAction; import com.aptana.shared_core.bindings.KeyBindingHelper; import com.aptana.shared_core.log.Log; import com.aptana.shared_core.string.StringUtils; /** * This is the viewer for the console. It's responsible for making sure that the actions the * user does are issued in the correct places in the document and that only editable places are * actually editable */ public class ScriptConsoleViewer extends TextConsoleViewer implements IScriptConsoleViewer, IScriptConsoleViewer2ForDocumentListener { /** * Boolean determining if we're currently requesting a completion */ private boolean inCompletion = false; /** * Holds the command history for the console */ private ScriptConsoleHistory history; /** * Listens and acts to document changes (and passes them to the shell) */ private ScriptConsoleDocumentListener listener; /** * Provides the colors for the console. */ IConsoleStyleProvider styleProvider; /** * Console itself */ protected ScriptConsole console; /** * Attribute defines if this is the main viewer (other viewers may be associated to the same document) */ private boolean isMainViewer; /** * This class is responsible for checking if commands should be issued or not given the command requested * and updating the caret to the correct position for it to happen (if needed). */ private final class KeyChecker implements VerifyKeyListener { private Method fHideMethod; private Method getHideMethod() { if (fHideMethod == null) { try { fHideMethod = ScriptConsoleViewer.this.fContentAssistant.getClass() .getDeclaredMethod("hide"); fHideMethod.setAccessible(true); } catch (Exception e) { Log.log(e); } } return fHideMethod; } public void verifyKey(VerifyEvent event) { try { if (event.character != '\0') { // Printable character if (Character.isLetter(event.character) && (event.stateMask == 0 || (event.stateMask & SWT.SHIFT) != 0) || Character.isWhitespace(event.character)) { //it's a valid letter without any stateMask (so, just entering regular text or upper/lowercase -- if shift is there). if (!isSelectedRangeEditable()) { getTextWidget().setCaretOffset(getDocument().getLength()); } } if (!isSelectedRangeEditable()) { event.doit = false; return; } if (event.character == SWT.CR || event.character == SWT.LF) { //if we had an enter with the shift pressed and we're in a completion, we must stop it if (inCompletion && (event.stateMask & SWT.SHIFT) != 0) { //Work-around the fact that hide() is a protected method. Method hideMethod = getHideMethod(); if (hideMethod != null) { hideMethod.invoke(ScriptConsoleViewer.this.fContentAssistant); } } if (!inCompletion) { //in a new line, always set the caret to the end of the document (if not in completion) //(note that when we make a hide in the previous 'if', it will automatically exit the //completion mode (so, it'll also get into this part of the code) getTextWidget().setCaretOffset(getDocument().getLength()); } return; } if (event.character == SWT.ESC) { if (!inCompletion) { //while in a completion, esc won't clear the line (just stop the completion) listener.setCommandLine(""); } return; } } else { //not printable char if (isCaretInEditableRange()) { if (!inCompletion && event.keyCode == SWT.PAGE_UP) { event.doit = false; List<String> commands = history.getAsList(); List<String> commandsToExecute = ScriptConsoleHistorySelector.select(commands); if (commandsToExecute != null) { //remove the current command (substituted by the one gotten from page up) listener.setCommandLine(""); IDocument d = getDocument(); //Pass them all at once (let the document listener separate the command in lines). d.replace(d.getLength(), 0, StringUtils.join("\n", commandsToExecute) + "\n"); } return; } } } } catch (Exception e) { Log.log(e); } } } /** * Marks if a history request just started. */ volatile int inHistoryRequests = 0; volatile boolean changedAfterLastHistoryRequest = false; private final boolean focusOnStart; /** * Handles a backspace (should guarantee that it does not delete things that are not in the last * line -- nor in the prompt) */ private AbstractHandleBackspaceAction handleBackspaceAction; /** * This is the text widget that's used to edit the console. It has some treatments to handle * commands that should act differently (special handling for when the caret is on the last line * to execute custom commands, such using the history, etc). */ private class ScriptConsoleStyledText extends StyledText { /** * Handles a delete previous word (should guarantee that it does not delete things that are not in the last * line -- nor in the prompt) */ private HandleDeletePreviousWord handleDeletePreviousWord; /** * Handles a line start action (home) stays within the same line changing from the * 1st char of text, beginning of prompt, beginning of line. */ private HandleLineStartAction handleLineStartAction; /** * Contains the caret offset that has been set from the console API. */ private volatile int internalCaretSet = -1; /** * Set to true when drag source/target are the same console */ private boolean thisConsoleInitiatedDrag = false; /** * Constructor. * * @param parent parent for the styled text * @param style style to be used */ public ScriptConsoleStyledText(Composite parent, int style) { super(parent, style); /** * The StyledText will change the caretOffset that we've updated during the modifications, * so, the verify and the extended modify listener will keep track if it actually does * that and will reset the caret to the position we actually added it. * * Feels like a hack but I couldn't find a better way to do it. */ addVerifyListener(new VerifyListener() { public void verifyText(VerifyEvent e) { internalCaretSet = -1; } }); /** * Set it to the location we've set it to be. */ addExtendedModifyListener(new ExtendedModifyListener() { public void modifyText(ExtendedModifyEvent event) { if (internalCaretSet != -1) { if (internalCaretSet != getCaretOffset()) { setCaretOffset(internalCaretSet); } internalCaretSet = -1; } } }); initDragDrop(); handleDeletePreviousWord = new HandleDeletePreviousWord(); handleLineStartAction = new HandleLineStartAction(); } private void initDragDrop() { DragSource dragSource = new DragSource(this, DND.DROP_COPY | DND.DROP_MOVE); dragSource.addDragListener(new DragSourceAdapter()); dragSource.setTransfer(new Transfer[] { org.eclipse.swt.dnd.TextTransfer.getInstance() }); DropTarget dropTarget = new DropTarget(this, DND.DROP_COPY | DND.DROP_MOVE); dropTarget.setTransfer(new Transfer[] { LocalSelectionTransfer.getTransfer(), org.eclipse.swt.dnd.TextTransfer.getInstance() }); dropTarget.addDropListener(new DragTargetAdapter()); } private final class DragSourceAdapter implements DragSourceListener { private Point selection; private String selectionText = null; private boolean selectionIsEditable; public void dragStart(DragSourceEvent event) { thisConsoleInitiatedDrag = false; selectionText = null; event.doit = false; if (getSelectedRange().y > 0) { String temp_selection = new ClipboardHandler().getPlainText(getDocument(), getSelectedRange()); if (temp_selection != null && temp_selection.length() > 0) { event.doit = true; selectionText = temp_selection; selection = getSelection(); selectionIsEditable = isSelectedRangeEditable(); } } } public void dragSetData(DragSourceEvent event) { if (TextTransfer.getInstance().isSupportedType(event.dataType)) { event.data = selectionText; thisConsoleInitiatedDrag = true; } } public void dragFinished(DragSourceEvent event) { try { if (event.detail == DND.DROP_MOVE && selectionIsEditable) { Point newSelection = getSelection(); int length = selection.y - selection.x; int delta = 0; if (newSelection.x < selection.x) delta = length; replaceTextRange(selection.x + delta, length, ""); } } finally { thisConsoleInitiatedDrag = false; } } } private final class DragTargetAdapter implements DropTargetListener { private SafeScriptConsoleCodeGenerator getSafeGenerator() { ISelection selection = LocalSelectionTransfer.getTransfer().getSelection(); IScriptConsoleCodeGenerator codeGenerator = PythonSnippetUtils .getScriptConsoleCodeGeneratorAdapter(selection); return new SafeScriptConsoleCodeGenerator(codeGenerator); } /** * We cancel the drop if we don't have anything to drop */ private boolean forceDropNone(DropTargetEvent event) { if (LocalSelectionTransfer.getTransfer().isSupportedType(event.currentDataType)) { IScriptConsoleCodeGenerator codeGenerator = getSafeGenerator(); if (codeGenerator == null || codeGenerator.hasPyCode() == false) { return true; } } return false; } private void adjustEventDetail(DropTargetEvent event) { if (forceDropNone(event)) { event.detail = DND.DROP_NONE; } else if (!thisConsoleInitiatedDrag && (event.operations & DND.DROP_COPY) != 0) { event.detail = DND.DROP_COPY; } else if ((event.operations & DND.DROP_MOVE) != 0) { event.detail = DND.DROP_MOVE; } else if ((event.operations & DND.DROP_COPY) != 0) { event.detail = DND.DROP_COPY; } else { event.detail = DND.DROP_NONE; } } public void dragEnter(DropTargetEvent event) { thisConsoleInitiatedDrag = false; adjustEventDetail(event); } public void dragOver(DropTargetEvent event) { event.feedback |= DND.FEEDBACK_SCROLL; } public void dragOperationChanged(DropTargetEvent event) { adjustEventDetail(event); } public void dropAccept(DropTargetEvent event) { adjustEventDetail(event); } public void drop(DropTargetEvent event) { if (event.operations == DND.DROP_NONE) { // nothing to do return; } String text = null; if (TextTransfer.getInstance().isSupportedType(event.currentDataType)) { text = (String) event.data; } else if (LocalSelectionTransfer.getTransfer().isSupportedType(event.currentDataType)) { IScriptConsoleCodeGenerator codeGenerator = getSafeGenerator(); if (codeGenerator != null) { text = codeGenerator.getPyCode(); } } if (text != null && text.length() > 0) { Point selectedRange = getSelectedRange(); if (selectedRange.x < getLastLineOffset()) { changeSelectionToEditableRange(); } else { int commandLineOffset = getCommandLineOffset(); if (selectedRange.x < commandLineOffset) { setSelectedRange(commandLineOffset, 0); } } // else, is in range Point newSelection = getSelection(); try { getDocument().replace(newSelection.x, 0, text); } catch (BadLocationException e) { return; } setSelectionRange(newSelection.x, text.length()); changeSelectionToEditableRange(); } } public void dragLeave(DropTargetEvent event) { } } /** * Overridden to keep track of changes in the caret. */ @Override public void setCaretOffset(int offset) { internalCaretSet = offset; super.setCaretOffset(offset); } /** * Execute some action. */ public void invokeAction(int action) { //some actions have a different scope (not in selected range / out of selected range) switch (action) { case ST.LINE_START: if (handleLineStartAction.execute(getDocument(), getCaretOffset(), getCommandLineOffset(), ScriptConsoleViewer.this)) { return; } else { super.invokeAction(action); } } if (isSelectedRangeEditable()) { try { int historyChange = 0; switch (action) { case ST.LINE_UP: historyChange = 1; break; case ST.LINE_DOWN: historyChange = 2; break; case ST.DELETE_PREVIOUS: handleBackspaceAction.execute(getDocument(), (ITextSelection) ScriptConsoleViewer.this.getSelection(), getCommandLineOffset()); return; case ST.DELETE_WORD_PREVIOUS: handleDeletePreviousWord.execute(getDocument(), getCaretOffset(), getCommandLineOffset()); return; } if (historyChange != 0) { if (changedAfterLastHistoryRequest) { //only set a new match if it didn't change since the last time we did an UP/DOWN history.setMatchStart(getCommandLine()); } boolean didChange; if (historyChange == 1) { didChange = history.prev(); } else { didChange = history.next(); } if (didChange) { inHistoryRequests += 1; try { listener.setCommandLine(history.get()); setCaretOffset(getDocument().getLength()); } finally { inHistoryRequests -= 1; } } changedAfterLastHistoryRequest = false; return; } } catch (BadLocationException e) { Log.log(e); return; } super.invokeAction(action); } else { //we're not in the editable range (so, as the command was already checked to be valid, //let's just let it keep its way) super.invokeAction(action); } } /** * When cutting something, we must be sure that it'll only mess with the contents * in the command line. */ @Override public void cut() { changeSelectionToEditableRange(); super.cut(); } /** * When pasting something, we must be sure that it'll only mess with the contents * in the command line. */ @Override public void paste() { changeSelectionToEditableRange(); super.paste(); } /** * When copying something, we don't want to copy the prompt contents. */ @Override public void copy() { copy(DND.CLIPBOARD); } /** * When copying something, we don't want to copy the prompt contents. */ @Override public void copy(int clipboardType) { checkWidget(); Point selectedRange = getSelectedRange(); if (selectedRange.y > 0) { IDocument doc = getDocument(); new ClipboardHandler().putIntoClipboard(doc, selectedRange, clipboardType, getDisplay()); } } /** * Changes the selected range to be all editable. */ protected void changeSelectionToEditableRange() { Point range = getSelectedRange(); int commandLineOffset = getCommandLineOffset(); int minOffset = range.x; int maxOffset = range.x + range.y; boolean changed = false; boolean goToEnd = false; if (minOffset < commandLineOffset) { minOffset = commandLineOffset; changed = true; } if (maxOffset < commandLineOffset) { maxOffset = commandLineOffset; changed = true; // Only go to the end of the buffer if the max offset isn't in range goToEnd = true; } if (changed) { setSelectedRange(minOffset, maxOffset - minOffset); } if (goToEnd) { setCaretOffset(getDocument().getLength()); } } } /** * @return the style provider that should be used. */ public IConsoleStyleProvider getStyleProvider() { return styleProvider; } /** * @return the caret offset (based on the document) */ public int getCaretOffset() { return getTextWidget().getCaretOffset(); } public Object getInterpreterInfo() { return this.console.getInterpreterInfo(); } /** * Sets the new caret position in the console. * * TODO: async should not be allowed (only clearing the shell at the constructor still uses that) */ public void setCaretOffset(final int offset, boolean async) { final StyledText textWidget = getTextWidget(); if (textWidget != null) { if (async) { Display display = textWidget.getDisplay(); if (display != null) { display.asyncExec(new Runnable() { public void run() { textWidget.setCaretOffset(offset); } }); } } else { textWidget.setCaretOffset(offset); } } } /** * @return true if the currently selected range is editable (all chars must be editable) */ protected boolean isSelectedRangeEditable() { Point range = getSelectedRange(); int commandLineOffset = getCommandLineOffset(); if (range.x < commandLineOffset) { return false; } if ((range.x + range.y) < commandLineOffset) { return false; } return true; } /** * @return true if the caret is currently in a position that can be edited. * @throws BadLocationException */ protected boolean isCaretInLastLine() throws BadLocationException { return getTextWidget().getCaretOffset() >= listener.getLastLineOffset(); } /** * @return true if the caret is currently in a position that can be edited. */ protected boolean isCaretInEditableRange() { return getTextWidget().getCaretOffset() >= getCommandLineOffset(); } /** * Creates the styled text for the console */ @Override protected StyledText createTextWidget(Composite parent, int styles) { return new ScriptConsoleStyledText(parent, styles); } /** * Constructor * * @param parent parent for this viewer * @param console the console that this viewer is showing * @param contentHandler */ public ScriptConsoleViewer(Composite parent, ScriptConsole console, final IScriptConsoleContentHandler contentHandler, IConsoleStyleProvider styleProvider, String initialCommands, boolean focusOnStart, AbstractHandleBackspaceAction handleBackspaceAction, IHandleScriptAutoEditStrategy strategy) { super(parent, console); this.handleBackspaceAction = handleBackspaceAction; this.focusOnStart = focusOnStart; this.console = console; this.getTextWidget().setBackground(console.getPydevConsoleBackground()); ScriptConsoleViewer existingViewer = this.console.getViewer(); if (existingViewer == null) { this.isMainViewer = true; this.console.setViewer(this); this.styleProvider = styleProvider; this.history = console.getHistory(); this.listener = new ScriptConsoleDocumentListener(this, console, console.getPrompt(), console.getHistory(), console.getLineTrackers(), initialCommands, strategy); this.listener.setDocument(getDocument()); } else { this.isMainViewer = false; this.styleProvider = existingViewer.styleProvider; this.history = existingViewer.history; this.listener = existingViewer.listener; this.listener.addViewer(this); } final StyledText styledText = getTextWidget(); //Added because we don't want the console to close when the user presses ESC //(as it would when it's on a floating window) //we do that because ESC is meant to clear the current line (and as such, //should do that action and not close the console). styledText.addTraverseListener(new TraverseListener() { public void keyTraversed(TraverseEvent e) { if (e.detail == SWT.TRAVERSE_ESCAPE) { e.doit = false; } } }); getDocument().addDocumentListener(new IDocumentListener() { public void documentAboutToBeChanged(DocumentEvent event) { } public void documentChanged(DocumentEvent event) { if (inHistoryRequests == 0) { changedAfterLastHistoryRequest = true; } } }); styledText.addFocusListener(new FocusListener() { /** * When the initial focus is gained, set the caret position to the last position (just after the prompt) */ public void focusGained(FocusEvent e) { setCaretOffset(getDocument().getLength(), true); //just a 1-time listener styledText.removeFocusListener(this); } public void focusLost(FocusEvent e) { } }); styledText.addVerifyKeyListener(new KeyChecker()); //content assist handling (we don't want to execute the event because the content assist handling //will be done here). //verify if it was a content assist styledText.addVerifyKeyListener(new VerifyKeyListener() { public void verifyKey(VerifyEvent event) { if (KeyBindingHelper.matchesContentAssistKeybinding(event) || KeyBindingHelper.matchesQuickAssistKeybinding(event)) { event.doit = false; return; } } }); //execute the content assist styledText.addKeyListener(new KeyListener() { public void keyPressed(KeyEvent e) { if (getCaretOffset() >= getCommandLineOffset()) { if (KeyBindingHelper.matchesContentAssistKeybinding(e)) { contentHandler.contentAssistRequired(); } else if (KeyBindingHelper.matchesQuickAssistKeybinding(e)) { contentHandler.quickAssistRequired(); } } } public void keyReleased(KeyEvent e) { } }); } public ScriptConsole getConsole() { return console; } /** * Listen to the completions because we've to know when we're doing a completion or not. */ @Override public void configure(SourceViewerConfiguration configuration) { super.configure(configuration); ICompletionListener completionListener = new ICompletionListener() { public void assistSessionStarted(ContentAssistEvent event) { inCompletion = true; } public void assistSessionEnded(ContentAssistEvent event) { inCompletion = false; } public void selectionChanged(ICompletionProposal proposal, boolean smartToggle) { } }; if (fContentAssistant != null) { ((IContentAssistantExtension2) fContentAssistant).addCompletionListener(completionListener); } if (fQuickAssistAssistant != null) { fQuickAssistAssistant.addCompletionListener(completionListener); } if (isMainViewer) { clear(true); } if (focusOnStart) { this.getTextWidget().setFocus(); } } /** * @return the contents of the current buffer (text edited still not passed to the shell) */ public String getCommandLine() { return listener.getCommandLine(); } /** * @return the offset where the current buffer starts (editable area of the document) */ public int getCommandLineOffset() { try { return listener.getCommandLineOffset(); } catch (BadLocationException e) { return -1; } } /** * @return the offset where the line containing the current buffer starts (editable area of the document) */ public int getLastLineOffset() { try { return listener.getLastLineOffset(); } catch (BadLocationException e) { return -1; } } /** * Used to clear the contents of the document */ public void clear(boolean addInitialCommands) { listener.clear(addInitialCommands); } /** * @return the last time the document shown in this viewer was edited. */ public long getLastChangeMillis() { return listener.getLastChangeMillis(); } /* * Overridden just to change visibility. * * (non-Javadoc) * @see org.eclipse.ui.console.TextConsoleViewer#revealEndOfDocument() */ @Override public void revealEndOfDocument() { super.revealEndOfDocument(); } }