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