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 * Returns the {@link IFile} matching the editor's input or null. 322 * <p/> 323 * By construction, the editor input has to be an {@link IFileEditorInput} so it must 324 * have an associated {@link IFile}. Null can only be returned if this editor has no 325 * input somehow. 326 */ 327 public IFile getFile() { 328 if (getEditorInput() instanceof IFileEditorInput) { 329 return ((IFileEditorInput) getEditorInput()).getFile(); 330 } 331 return null; 332 } 333 334 /** 335 * Removes attached listeners. 336 * 337 * @see WorkbenchPart 338 */ 339 @Override 340 public void dispose() { 341 ResourcesPlugin.getWorkspace().removeResourceChangeListener(this); 342 343 super.dispose(); 344 } 345 346 /** 347 * Commit all dirty pages then saves the contents of the text editor. 348 * <p/> 349 * This works by committing all data to the XML model and then 350 * asking the Structured XML Editor to save the XML. 351 * 352 * @see IEditorPart 353 */ 354 @Override 355 public void doSave(IProgressMonitor monitor) { 356 commitPages(true /* onSave */); 357 358 // The actual "save" operation is done by the Structured XML Editor 359 getEditor(mTextPageIndex).doSave(monitor); 360 } 361 362 /* (non-Javadoc) 363 * Saves the contents of this editor to another object. 364 * <p> 365 * Subclasses must override this method to implement the open-save-close lifecycle 366 * for an editor. For greater details, see <code>IEditorPart</code> 367 * </p> 368 * 369 * @see IEditorPart 370 */ 371 @Override 372 public void doSaveAs() { 373 commitPages(true /* onSave */); 374 375 IEditorPart editor = getEditor(mTextPageIndex); 376 editor.doSaveAs(); 377 setPageText(mTextPageIndex, editor.getTitle()); 378 setInput(editor.getEditorInput()); 379 } 380 381 /** 382 * Commits all dirty pages in the editor. This method should 383 * be called as a first step of a 'save' operation. 384 * <p/> 385 * This is the same implementation as in {@link FormEditor} 386 * except it fixes two bugs: a cast to IFormPage is done 387 * from page.get(i) <em>before</em> being tested with instanceof. 388 * Another bug is that the last page might be a null pointer. 389 * <p/> 390 * The incorrect casting makes the original implementation crash due 391 * to our {@link StructuredTextEditor} not being an {@link IFormPage} 392 * so we have to override and duplicate to fix it. 393 * 394 * @param onSave <code>true</code> if commit is performed as part 395 * of the 'save' operation, <code>false</code> otherwise. 396 * @since 3.3 397 */ 398 @Override 399 public void commitPages(boolean onSave) { 400 if (pages != null) { 401 for (int i = 0; i < pages.size(); i++) { 402 Object page = pages.get(i); 403 if (page != null && page instanceof IFormPage) { 404 IFormPage form_page = (IFormPage) page; 405 IManagedForm managed_form = form_page.getManagedForm(); 406 if (managed_form != null && managed_form.isDirty()) { 407 managed_form.commit(onSave); 408 } 409 } 410 } 411 } 412 } 413 414 /* (non-Javadoc) 415 * Returns whether the "save as" operation is supported by this editor. 416 * <p> 417 * Subclasses must override this method to implement the open-save-close lifecycle 418 * for an editor. For greater details, see <code>IEditorPart</code> 419 * </p> 420 * 421 * @see IEditorPart 422 */ 423 @Override 424 public boolean isSaveAsAllowed() { 425 return false; 426 } 427 428 // ---- Local methods ---- 429 430 431 /** 432 * Helper method that creates a new hyper-link Listener. 433 * Used by derived classes which need active links in {@link FormText}. 434 * <p/> 435 * This link listener handles two kinds of URLs: 436 * <ul> 437 * <li> Links starting with "http" are simply sent to a local browser. 438 * <li> Links starting with "file:/" are simply sent to a local browser. 439 * <li> Links starting with "page:" are expected to be an editor page id to switch to. 440 * <li> Other links are ignored. 441 * </ul> 442 * 443 * @return A new hyper-link listener for FormText to use. 444 */ 445 public final IHyperlinkListener createHyperlinkListener() { 446 return new HyperlinkAdapter() { 447 /** 448 * Switch to the page corresponding to the link that has just been clicked. 449 * For this purpose, the HREF of the <a> tags above is the page ID to switch to. 450 */ 451 @Override 452 public void linkActivated(HyperlinkEvent e) { 453 super.linkActivated(e); 454 String link = e.data.toString(); 455 if (link.startsWith("http") || //$NON-NLS-1$ 456 link.startsWith("file:/")) { //$NON-NLS-1$ 457 openLinkInBrowser(link); 458 } else if (link.startsWith("page:")) { //$NON-NLS-1$ 459 // Switch to an internal page 460 setActivePage(link.substring(5 /* strlen("page:") */)); 461 } 462 } 463 }; 464 } 465 466 /** 467 * Open the http link into a browser 468 * 469 * @param link The URL to open in a browser 470 */ 471 private void openLinkInBrowser(String link) { 472 try { 473 IWorkbenchBrowserSupport wbs = WorkbenchBrowserSupport.getInstance(); 474 wbs.createBrowser(BROWSER_ID).openURL(new URL(link)); 475 } catch (PartInitException e1) { 476 // pass 477 } catch (MalformedURLException e1) { 478 // pass 479 } 480 } 481 482 /** 483 * Creates the XML source editor. 484 * <p/> 485 * Memorizes the index page of the source editor (it's always the last page, but the number 486 * of pages before can change.) 487 * <br/> 488 * Retrieves the underlying XML model from the StructuredEditor and attaches a listener to it. 489 * Finally triggers modelChanged() on the model listener -- derived classes can use this 490 * to initialize the model the first time. 491 * <p/> 492 * Called only once <em>after</em> createFormPages. 493 */ 494 private void createTextEditor() { 495 try { 496 mTextEditor = new TextEditor(); 497 int index = addPage(mTextEditor, getEditorInput()); 498 mTextPageIndex = index; 499 setPageText(index, mTextEditor.getTitle()); 500 501 IDocumentProvider provider = mTextEditor.getDocumentProvider(); 502 mDocument = provider.getDocument(getEditorInput()); 503 504 mDocument.addDocumentListener(new IDocumentListener() { 505 public void documentChanged(DocumentEvent event) { 506 onDocumentChanged(event); 507 } 508 509 public void documentAboutToBeChanged(DocumentEvent event) { 510 // ignore 511 } 512 }); 513 514 515 } catch (PartInitException e) { 516 ErrorDialog.openError(getSite().getShell(), 517 "Android Text Editor Error", null, e.getStatus()); 518 } 519 } 520 521 /** 522 * Gives access to the {@link IDocument} from the {@link TextEditor}, corresponding to 523 * the current file input. 524 * <p/> 525 * All edits should be wrapped in a {@link #wrapRewriteSession(Runnable)}. 526 * The actual document instance is a {@link SynchronizableDocument}, which creates a lock 527 * around read/set operations. The base API provided by {@link IDocument} provides ways to 528 * manipulate the document line per line or as a bulk. 529 */ 530 public IDocument getDocument() { 531 return mDocument; 532 } 533 534 /** 535 * Returns the {@link IProject} for the edited file. 536 */ 537 public IProject getProject() { 538 if (mTextEditor != null) { 539 IEditorInput input = mTextEditor.getEditorInput(); 540 if (input instanceof FileEditorInput) { 541 FileEditorInput fileInput = (FileEditorInput)input; 542 IFile inputFile = fileInput.getFile(); 543 544 if (inputFile != null) { 545 return inputFile.getProject(); 546 } 547 } 548 } 549 550 return null; 551 } 552 553 /** 554 * Runs the given operation in the context of a document RewriteSession. 555 * Takes care of properly starting and stopping the operation. 556 * <p/> 557 * The operation itself should just access {@link #getDocument()} and use the 558 * normal document's API to manipulate it. 559 * 560 * @see #getDocument() 561 */ 562 public void wrapRewriteSession(Runnable operation) { 563 if (mDocument instanceof IDocumentExtension4) { 564 IDocumentExtension4 doc4 = (IDocumentExtension4) mDocument; 565 566 DocumentRewriteSession session = null; 567 try { 568 session = doc4.startRewriteSession(DocumentRewriteSessionType.UNRESTRICTED_SMALL); 569 570 operation.run(); 571 } catch(IllegalStateException e) { 572 AdtPlugin.log(e, "wrapRewriteSession failed"); 573 e.printStackTrace(); 574 } finally { 575 if (session != null) { 576 doc4.stopRewriteSession(session); 577 } 578 } 579 580 } else { 581 // Not an IDocumentExtension4? Unlikely. Try the operation anyway. 582 operation.run(); 583 } 584 } 585 586 } 587