1 /* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Eclipse Public License, Version 1.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.eclipse.org/org/documents/epl-v10.php 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.ide.eclipse.adt.internal.editors; 18 19 import com.android.ide.eclipse.adt.AdtPlugin; 20 21 import org.eclipse.core.internal.filebuffers.SynchronizableDocument; 22 import org.eclipse.core.resources.IFile; 23 import org.eclipse.core.resources.IProject; 24 import org.eclipse.core.resources.IResource; 25 import org.eclipse.core.resources.IResourceChangeEvent; 26 import org.eclipse.core.resources.IResourceChangeListener; 27 import org.eclipse.core.resources.ResourcesPlugin; 28 import org.eclipse.core.runtime.CoreException; 29 import org.eclipse.core.runtime.IProgressMonitor; 30 import org.eclipse.core.runtime.QualifiedName; 31 import org.eclipse.jface.action.IAction; 32 import org.eclipse.jface.dialogs.ErrorDialog; 33 import org.eclipse.jface.text.DocumentEvent; 34 import org.eclipse.jface.text.DocumentRewriteSession; 35 import org.eclipse.jface.text.DocumentRewriteSessionType; 36 import org.eclipse.jface.text.IDocument; 37 import org.eclipse.jface.text.IDocumentExtension4; 38 import org.eclipse.jface.text.IDocumentListener; 39 import org.eclipse.swt.widgets.Display; 40 import org.eclipse.ui.IActionBars; 41 import org.eclipse.ui.IEditorInput; 42 import org.eclipse.ui.IEditorPart; 43 import org.eclipse.ui.IEditorSite; 44 import org.eclipse.ui.IFileEditorInput; 45 import org.eclipse.ui.IWorkbenchPage; 46 import org.eclipse.ui.PartInitException; 47 import org.eclipse.ui.actions.ActionFactory; 48 import org.eclipse.ui.browser.IWorkbenchBrowserSupport; 49 import org.eclipse.ui.editors.text.TextEditor; 50 import org.eclipse.ui.forms.IManagedForm; 51 import org.eclipse.ui.forms.editor.FormEditor; 52 import org.eclipse.ui.forms.editor.IFormPage; 53 import org.eclipse.ui.forms.events.HyperlinkAdapter; 54 import org.eclipse.ui.forms.events.HyperlinkEvent; 55 import org.eclipse.ui.forms.events.IHyperlinkListener; 56 import org.eclipse.ui.forms.widgets.FormText; 57 import org.eclipse.ui.internal.browser.WorkbenchBrowserSupport; 58 import org.eclipse.ui.part.FileEditorInput; 59 import org.eclipse.ui.part.MultiPageEditorPart; 60 import org.eclipse.ui.part.WorkbenchPart; 61 import org.eclipse.ui.texteditor.IDocumentProvider; 62 import org.eclipse.wst.sse.ui.StructuredTextEditor; 63 64 import java.net.MalformedURLException; 65 import java.net.URL; 66 67 /** 68 * Multi-page form editor for Android text files. 69 * <p/> 70 * It is designed to work with a {@link TextEditor} that will display a text file. 71 * <br/> 72 * Derived classes must implement createFormPages to create the forms before the 73 * source editor. This can be a no-op if desired. 74 */ 75 public abstract class AndroidTextEditor extends FormEditor implements IResourceChangeListener { 76 77 /** Preference name for the current page of this file */ 78 private static final String PREF_CURRENT_PAGE = "_current_page"; 79 80 /** Id string used to create the Android SDK browser */ 81 private static String BROWSER_ID = "android"; // $NON-NLS-1$ 82 83 /** Page id of the XML source editor, used for switching tabs programmatically */ 84 public final static String TEXT_EDITOR_ID = "editor_part"; //$NON-NLS-1$ 85 86 /** Width hint for text fields. Helps the grid layout resize properly on smaller screens */ 87 public static final int TEXT_WIDTH_HINT = 50; 88 89 /** Page index of the text editor (always the last page) */ 90 private int mTextPageIndex; 91 92 /** The text editor */ 93 private TextEditor mTextEditor; 94 95 /** flag set during page creation */ 96 private boolean mIsCreatingPage = false; 97 98 private IDocument mDocument; 99 100 /** 101 * Creates a form editor. 102 */ 103 public AndroidTextEditor() { 104 super(); 105 } 106 107 // ---- Abstract Methods ---- 108 109 /** 110 * Creates the various form pages. 111 * <p/> 112 * Derived classes must implement this to add their own specific tabs. 113 */ 114 abstract protected void createFormPages(); 115 116 /** 117 * Called by the base class {@link AndroidTextEditor} once all pages (custom form pages 118 * as well as text editor page) have been created. This give a chance to deriving 119 * classes to adjust behavior once the text page has been created. 120 */ 121 protected void postCreatePages() { 122 // Nothing in the base class. 123 } 124 125 /** 126 * Subclasses should override this method to process the new text model. 127 * This is called after the document has been edited. 128 * 129 * The base implementation is empty. 130 * 131 * @param event Specification of changes applied to document. 132 */ 133 protected void onDocumentChanged(DocumentEvent event) { 134 // pass 135 } 136 137 // ---- Base Class Overrides, Interfaces Implemented ---- 138 139 /** 140 * Creates the pages of the multi-page editor. 141 */ 142 @Override 143 protected void addPages() { 144 createAndroidPages(); 145 selectDefaultPage(null /* defaultPageId */); 146 } 147 148 /** 149 * Creates the page for the Android Editors 150 */ 151 protected void createAndroidPages() { 152 mIsCreatingPage = true; 153 createFormPages(); 154 createTextEditor(); 155 createUndoRedoActions(); 156 postCreatePages(); 157 mIsCreatingPage = false; 158 } 159 160 /** 161 * Returns whether the editor is currently creating its pages. 162 */ 163 public boolean isCreatingPages() { 164 return mIsCreatingPage; 165 } 166 167 /** 168 * Creates undo redo actions for the editor site (so that it works for any page of this 169 * multi-page editor) by re-using the actions defined by the {@link TextEditor} 170 * (aka the XML text editor.) 171 */ 172 private void createUndoRedoActions() { 173 IActionBars bars = getEditorSite().getActionBars(); 174 if (bars != null) { 175 IAction action = mTextEditor.getAction(ActionFactory.UNDO.getId()); 176 bars.setGlobalActionHandler(ActionFactory.UNDO.getId(), action); 177 178 action = mTextEditor.getAction(ActionFactory.REDO.getId()); 179 bars.setGlobalActionHandler(ActionFactory.REDO.getId(), action); 180 181 bars.updateActionBars(); 182 } 183 } 184 185 /** 186 * Selects the default active page. 187 * @param defaultPageId the id of the page to show. If <code>null</code> the editor attempts to 188 * find the default page in the properties of the {@link IResource} object being edited. 189 */ 190 protected void selectDefaultPage(String defaultPageId) { 191 if (defaultPageId == null) { 192 if (getEditorInput() instanceof IFileEditorInput) { 193 IFile file = ((IFileEditorInput) getEditorInput()).getFile(); 194 195 QualifiedName qname = new QualifiedName(AdtPlugin.PLUGIN_ID, 196 getClass().getSimpleName() + PREF_CURRENT_PAGE); 197 String pageId; 198 try { 199 pageId = file.getPersistentProperty(qname); 200 if (pageId != null) { 201 defaultPageId = pageId; 202 } 203 } catch (CoreException e) { 204 // ignored 205 } 206 } 207 } 208 209 if (defaultPageId != null) { 210 try { 211 setActivePage(Integer.parseInt(defaultPageId)); 212 } catch (Exception e) { 213 // We can get NumberFormatException from parseInt but also 214 // AssertionError from setActivePage when the index is out of bounds. 215 // Generally speaking we just want to ignore any exception and fall back on the 216 // first page rather than crash the editor load. Logging the error is enough. 217 AdtPlugin.log(e, "Selecting page '%s' in AndroidXmlEditor failed", defaultPageId); 218 } 219 } 220 } 221 222 /** 223 * Removes all the pages from the editor. 224 */ 225 protected void removePages() { 226 int count = getPageCount(); 227 for (int i = count - 1 ; i >= 0 ; i--) { 228 removePage(i); 229 } 230 } 231 232 /** 233 * Overrides the parent's setActivePage to be able to switch to the xml editor. 234 * 235 * If the special pageId TEXT_EDITOR_ID is given, switches to the mTextPageIndex page. 236 * This is needed because the editor doesn't actually derive from IFormPage and thus 237 * doesn't have the get-by-page-id method. In this case, the method returns null since 238 * IEditorPart does not implement IFormPage. 239 */ 240 @Override 241 public IFormPage setActivePage(String pageId) { 242 if (pageId.equals(TEXT_EDITOR_ID)) { 243 super.setActivePage(mTextPageIndex); 244 return null; 245 } else { 246 return super.setActivePage(pageId); 247 } 248 } 249 250 251 /** 252 * Notifies this multi-page editor that the page with the given id has been 253 * activated. This method is called when the user selects a different tab. 254 * 255 * @see MultiPageEditorPart#pageChange(int) 256 */ 257 @Override 258 protected void pageChange(int newPageIndex) { 259 super.pageChange(newPageIndex); 260 261 // Do not record page changes during creation of pages 262 if (mIsCreatingPage) { 263 return; 264 } 265 266 if (getEditorInput() instanceof IFileEditorInput) { 267 IFile file = ((IFileEditorInput) getEditorInput()).getFile(); 268 269 QualifiedName qname = new QualifiedName(AdtPlugin.PLUGIN_ID, 270 getClass().getSimpleName() + PREF_CURRENT_PAGE); 271 try { 272 file.setPersistentProperty(qname, Integer.toString(newPageIndex)); 273 } catch (CoreException e) { 274 // ignore 275 } 276 } 277 } 278 279 /** 280 * Notifies this listener that some resource changes 281 * are happening, or have already happened. 282 * 283 * Closes all project files on project close. 284 * @see IResourceChangeListener 285 */ 286 public void resourceChanged(final IResourceChangeEvent event) { 287 if (event.getType() == IResourceChangeEvent.PRE_CLOSE) { 288 Display.getDefault().asyncExec(new Runnable() { 289 public void run() { 290 IWorkbenchPage[] pages = getSite().getWorkbenchWindow() 291 .getPages(); 292 for (int i = 0; i < pages.length; i++) { 293 if (((FileEditorInput)mTextEditor.getEditorInput()) 294 .getFile().getProject().equals( 295 event.getResource())) { 296 IEditorPart editorPart = pages[i].findEditor(mTextEditor 297 .getEditorInput()); 298 pages[i].closeEditor(editorPart, true); 299 } 300 } 301 } 302 }); 303 } 304 } 305 306 /** 307 * Initializes the editor part with a site and input. 308 * <p/> 309 * Checks that the input is an instance of {@link IFileEditorInput}. 310 * 311 * @see FormEditor 312 */ 313 @Override 314 public void init(IEditorSite site, IEditorInput editorInput) throws PartInitException { 315 if (!(editorInput instanceof IFileEditorInput)) 316 throw new PartInitException("Invalid Input: Must be IFileEditorInput"); 317 super.init(site, editorInput); 318 } 319 320 /** 321 * Removes attached listeners. 322 * 323 * @see WorkbenchPart 324 */ 325 @Override 326 public void dispose() { 327 ResourcesPlugin.getWorkspace().removeResourceChangeListener(this); 328 329 super.dispose(); 330 } 331 332 /** 333 * Commit all dirty pages then saves the contents of the text editor. 334 * <p/> 335 * This works by committing all data to the XML model and then 336 * asking the Structured XML Editor to save the XML. 337 * 338 * @see IEditorPart 339 */ 340 @Override 341 public void doSave(IProgressMonitor monitor) { 342 commitPages(true /* onSave */); 343 344 // The actual "save" operation is done by the Structured XML Editor 345 getEditor(mTextPageIndex).doSave(monitor); 346 } 347 348 /* (non-Javadoc) 349 * Saves the contents of this editor to another object. 350 * <p> 351 * Subclasses must override this method to implement the open-save-close lifecycle 352 * for an editor. For greater details, see <code>IEditorPart</code> 353 * </p> 354 * 355 * @see IEditorPart 356 */ 357 @Override 358 public void doSaveAs() { 359 commitPages(true /* onSave */); 360 361 IEditorPart editor = getEditor(mTextPageIndex); 362 editor.doSaveAs(); 363 setPageText(mTextPageIndex, editor.getTitle()); 364 setInput(editor.getEditorInput()); 365 } 366 367 /** 368 * Commits all dirty pages in the editor. This method should 369 * be called as a first step of a 'save' operation. 370 * <p/> 371 * This is the same implementation as in {@link FormEditor} 372 * except it fixes two bugs: a cast to IFormPage is done 373 * from page.get(i) <em>before</em> being tested with instanceof. 374 * Another bug is that the last page might be a null pointer. 375 * <p/> 376 * The incorrect casting makes the original implementation crash due 377 * to our {@link StructuredTextEditor} not being an {@link IFormPage} 378 * so we have to override and duplicate to fix it. 379 * 380 * @param onSave <code>true</code> if commit is performed as part 381 * of the 'save' operation, <code>false</code> otherwise. 382 * @since 3.3 383 */ 384 @Override 385 public void commitPages(boolean onSave) { 386 if (pages != null) { 387 for (int i = 0; i < pages.size(); i++) { 388 Object page = pages.get(i); 389 if (page != null && page instanceof IFormPage) { 390 IFormPage form_page = (IFormPage) page; 391 IManagedForm managed_form = form_page.getManagedForm(); 392 if (managed_form != null && managed_form.isDirty()) { 393 managed_form.commit(onSave); 394 } 395 } 396 } 397 } 398 } 399 400 /* (non-Javadoc) 401 * Returns whether the "save as" operation is supported by this editor. 402 * <p> 403 * Subclasses must override this method to implement the open-save-close lifecycle 404 * for an editor. For greater details, see <code>IEditorPart</code> 405 * </p> 406 * 407 * @see IEditorPart 408 */ 409 @Override 410 public boolean isSaveAsAllowed() { 411 return false; 412 } 413 414 // ---- Local methods ---- 415 416 417 /** 418 * Helper method that creates a new hyper-link Listener. 419 * Used by derived classes which need active links in {@link FormText}. 420 * <p/> 421 * This link listener handles two kinds of URLs: 422 * <ul> 423 * <li> Links starting with "http" are simply sent to a local browser. 424 * <li> Links starting with "file:/" are simply sent to a local browser. 425 * <li> Links starting with "page:" are expected to be an editor page id to switch to. 426 * <li> Other links are ignored. 427 * </ul> 428 * 429 * @return A new hyper-link listener for FormText to use. 430 */ 431 public final IHyperlinkListener createHyperlinkListener() { 432 return new HyperlinkAdapter() { 433 /** 434 * Switch to the page corresponding to the link that has just been clicked. 435 * For this purpose, the HREF of the <a> tags above is the page ID to switch to. 436 */ 437 @Override 438 public void linkActivated(HyperlinkEvent e) { 439 super.linkActivated(e); 440 String link = e.data.toString(); 441 if (link.startsWith("http") || //$NON-NLS-1$ 442 link.startsWith("file:/")) { //$NON-NLS-1$ 443 openLinkInBrowser(link); 444 } else if (link.startsWith("page:")) { //$NON-NLS-1$ 445 // Switch to an internal page 446 setActivePage(link.substring(5 /* strlen("page:") */)); 447 } 448 } 449 }; 450 } 451 452 /** 453 * Open the http link into a browser 454 * 455 * @param link The URL to open in a browser 456 */ 457 private void openLinkInBrowser(String link) { 458 try { 459 IWorkbenchBrowserSupport wbs = WorkbenchBrowserSupport.getInstance(); 460 wbs.createBrowser(BROWSER_ID).openURL(new URL(link)); 461 } catch (PartInitException e1) { 462 // pass 463 } catch (MalformedURLException e1) { 464 // pass 465 } 466 } 467 468 /** 469 * Creates the XML source editor. 470 * <p/> 471 * Memorizes the index page of the source editor (it's always the last page, but the number 472 * of pages before can change.) 473 * <br/> 474 * Retrieves the underlying XML model from the StructuredEditor and attaches a listener to it. 475 * Finally triggers modelChanged() on the model listener -- derived classes can use this 476 * to initialize the model the first time. 477 * <p/> 478 * Called only once <em>after</em> createFormPages. 479 */ 480 private void createTextEditor() { 481 try { 482 mTextEditor = new TextEditor(); 483 int index = addPage(mTextEditor, getEditorInput()); 484 mTextPageIndex = index; 485 setPageText(index, mTextEditor.getTitle()); 486 487 IDocumentProvider provider = mTextEditor.getDocumentProvider(); 488 mDocument = provider.getDocument(getEditorInput()); 489 490 mDocument.addDocumentListener(new IDocumentListener() { 491 public void documentChanged(DocumentEvent event) { 492 onDocumentChanged(event); 493 } 494 495 public void documentAboutToBeChanged(DocumentEvent event) { 496 // ignore 497 } 498 }); 499 500 501 } catch (PartInitException e) { 502 ErrorDialog.openError(getSite().getShell(), 503 "Android Text Editor Error", null, e.getStatus()); 504 } 505 } 506 507 /** 508 * Gives access to the {@link IDocument} from the {@link TextEditor}, corresponding to 509 * the current file input. 510 * <p/> 511 * All edits should be wrapped in a {@link #wrapRewriteSession(Runnable)}. 512 * The actual document instance is a {@link SynchronizableDocument}, which creates a lock 513 * around read/set operations. The base API provided by {@link IDocument} provides ways to 514 * manipulate the document line per line or as a bulk. 515 */ 516 public IDocument getDocument() { 517 return mDocument; 518 } 519 520 /** 521 * Returns the {@link IProject} for the edited file. 522 */ 523 public IProject getProject() { 524 if (mTextEditor != null) { 525 IEditorInput input = mTextEditor.getEditorInput(); 526 if (input instanceof FileEditorInput) { 527 FileEditorInput fileInput = (FileEditorInput)input; 528 IFile inputFile = fileInput.getFile(); 529 530 if (inputFile != null) { 531 return inputFile.getProject(); 532 } 533 } 534 } 535 536 return null; 537 } 538 539 /** 540 * Runs the given operation in the context of a document RewriteSession. 541 * Takes care of properly starting and stopping the operation. 542 * <p/> 543 * The operation itself should just access {@link #getDocument()} and use the 544 * normal document's API to manipulate it. 545 * 546 * @see #getDocument() 547 */ 548 public void wrapRewriteSession(Runnable operation) { 549 if (mDocument instanceof IDocumentExtension4) { 550 IDocumentExtension4 doc4 = (IDocumentExtension4) mDocument; 551 552 DocumentRewriteSession session = null; 553 try { 554 session = doc4.startRewriteSession(DocumentRewriteSessionType.UNRESTRICTED_SMALL); 555 556 operation.run(); 557 } catch(IllegalStateException e) { 558 AdtPlugin.log(e, "wrapRewriteSession failed"); 559 e.printStackTrace(); 560 } finally { 561 if (session != null) { 562 doc4.stopRewriteSession(session); 563 } 564 } 565 566 } else { 567 // Not an IDocumentExtension4? Unlikely. Try the operation anyway. 568 operation.run(); 569 } 570 } 571 572 } 573