/* * 11/25/2008 * * TextEditorPane.java - A syntax highlighting text area that has knowledge of * the file it is editing on disk. * Copyright (C) 2008 Robert Futrell * robert_futrell at users.sourceforge.net * http://fifesoft.com/rsyntaxtextarea * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. */ package org.fife.ui.rsyntaxtextarea; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.nio.charset.Charset; import java.nio.charset.UnsupportedCharsetException; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.text.Document; import org.fife.io.UnicodeReader; import org.fife.io.UnicodeWriter; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.fife.ui.rtextarea.RTextAreaEditorKit; /** * An extension of {@link org.fife.ui.rsyntaxtextarea.RSyntaxTextArea} that adds information about the file being * edited, such as: * * <ul> * <li>Its name and location. * <li>Is it dirty? * <li>Is it read-only? * <li>The last time it was loaded or saved to disk (local files only). * <li>The file's encoding on disk. * <li>Easy access to the line separator. * </ul> * * Loading and saving is also built into the editor. * <p> * Both local and remote files (e.g. ftp) are supported. See the {@link FileLocation} class for more information. * * @author Robert Futrell * @version 1.0 * @see FileLocation */ public class TextEditorPane extends RSyntaxTextArea implements DocumentListener { private static final long serialVersionUID = 1L; public static final String FULL_PATH_PROPERTY = "TextEditorPane.fileFullPath"; public static final String DIRTY_PROPERTY = "TextEditorPane.dirty"; public static final String READ_ONLY_PROPERTY = "TextEditorPane.readOnly"; /** * The location of the file being edited. */ private FileLocation loc; /** * The charset to use when reading or writing this file. */ private String charSet; /** * Whether the file should be treated as read-only. */ private boolean readOnly; /** * Whether the file is dirty. */ private boolean dirty; /** * The last time this file was modified on disk, for local files. For remote files, this value should always be * {@link #LAST_MODIFIED_UNKNOWN}. */ private long lastSaveOrLoadTime; /** * The value returned by {@link #getLastSaveOrLoadTime()} for remote files. */ public static final long LAST_MODIFIED_UNKNOWN = 0; /** * The default name given to files if none is specified in a constructor. */ private static final String DEFAULT_FILE_NAME = "Untitled.txt"; /** * Constructor. The file will be given a default name. */ public TextEditorPane() { this(INSERT_MODE); } /** * Constructor. The file will be given a default name. * * @param textMode * Either <code>INSERT_MODE</code> or <code>OVERWRITE_MODE</code>. */ public TextEditorPane(int textMode) { this(textMode, false); } /** * Creates a new <code>TextEditorPane</code>. The file will be given a default name. * * @param textMode * Either <code>INSERT_MODE</code> or <code>OVERWRITE_MODE</code>. * @param wordWrapEnabled * Whether or not to use word wrap in this pane. */ public TextEditorPane(int textMode, boolean wordWrapEnabled) { super(textMode); setLineWrap(wordWrapEnabled); try { init(null, null); } catch (IOException ioe) { // Never happens ioe.printStackTrace(); } } /** * Creates a new <code>TextEditorPane</code>. * * @param textMode * Either <code>INSERT_MODE</code> or <code>OVERWRITE_MODE</code>. * @param wordWrapEnabled * Whether or not to use word wrap in this pane. * @param loc * The location of the text file being edited. If this value is <code>null</code>, a file named * "Untitled.txt" in the current directory is used. * @throws IOException * If an IO error occurs reading the file at <code>loc</code>. This of course won't happen if * <code>loc</code> is <code>null</code>. */ public TextEditorPane(int textMode, boolean wordWrapEnabled, FileLocation loc) throws IOException { this(textMode, wordWrapEnabled, loc, null); } /** * Creates a new <code>TextEditorPane</code>. * * @param textMode * Either <code>INSERT_MODE</code> or <code>OVERWRITE_MODE</code>. * @param wordWrapEnabled * Whether or not to use word wrap in this pane. * @param loc * The location of the text file being edited. If this value is <code>null</code>, a file named * "Untitled.txt" in the current directory is used. This file is displayed as empty even if it actually * exists. * @param defaultEnc * The default encoding to use when opening the file, if the file is not Unicode. If this value is * <code>null</code>, a system default value is used. * @throws IOException * If an IO error occurs reading the file at <code>loc</code>. This of course won't happen if * <code>loc</code> is <code>null</code>. */ public TextEditorPane(int textMode, boolean wordWrapEnabled, FileLocation loc, String defaultEnc) throws IOException { super(textMode); setLineWrap(wordWrapEnabled); init(loc, defaultEnc); } /** * Callback for when styles in the current document change. This method is never called. * * @param e * The document event. */ public void changedUpdate(DocumentEvent e) { } /** * Returns the default encoding for this operating system. * * @return The default encoding. */ private static final String getDefaultEncoding() { // TODO: Change to "Charset.defaultCharset().name()" when 1.4 support // is no longer needed. // NOTE: The "file.encoding" property is not guaranteed to be set by // the spec, so we cannot rely on it. String encoding = System.getProperty("file.encoding"); if (encoding == null) { try { File f = File.createTempFile("rsta", null); FileWriter w = new FileWriter(f); encoding = w.getEncoding(); w.close(); f.deleteOnExit();// delete(); Keep FindBugs happy } catch (IOException ioe) { encoding = "US-ASCII"; } } return encoding; } /** * Returns the encoding to use when reading or writing this file. * * @return The encoding. * @see #setEncoding(String) */ public String getEncoding() { return charSet; } /** * Returns the full path to this document. * * @return The full path to the document. */ public String getFileFullPath() { return loc.getFileFullPath(); } /** * Returns the file name of this document. * * @return The file name. */ public String getFileName() { return loc.getFileName(); } /** * Returns the timestamp for when this file was last loaded or saved <em>by this editor pane</em>. If the file has * been modified on disk by another process after it was loaded into this editor pane, this method will not return * the actual file's last modified time. * <p> * * For remote files, this method will always return {@link #LAST_MODIFIED_UNKNOWN}. * * @return The timestamp when this file was last loaded or saved by this editor pane, if it is a local file, or * {@link #LAST_MODIFIED_UNKNOWN} if it is a remote file. * @see #isModifiedOutsideEditor() */ public long getLastSaveOrLoadTime() { return lastSaveOrLoadTime; } /** * Returns the line separator used when writing this file (e.g. "<code>\n</code>", "<code>\r\n</code>", or " * <code>\r</code>"). * <p> * * Note that this value is an <code>Object</code> and not a <code>String</code> as that is the way the * {@link Document} interface defines its property values. If you always use {@link #setLineSeparator(String)} to * modify this value, then the value returned from this method will always be a <code>String</code>. * * @return The line separator. If this value is <code>null</code>, then the system default line separator is used * (usually the value of <code>System.getProperty("line.separator")</code>). * @see #setLineSeparator(String) * @see #setLineSeparator(String, boolean) */ public Object getLineSeparator() { return getDocument().getProperty( RTextAreaEditorKit.EndOfLineStringProperty); } /** * Initializes this editor with the specified file location. * * @param loc * The file location. If this is <code>null</code>, a default location is used and an empty file is * displayed. * @param defaultEnc * The default encoding to use when opening the file, if the file is not Unicode. If this value is * <code>null</code>, a system default value is used. * @throws IOException * If an IO error occurs reading from <code>loc</code>. If <code>loc</code> is <code>null</code>, this * cannot happen. */ private void init(FileLocation loc, String defaultEnc) throws IOException { if (loc == null) { // Don't call load() just in case Untitled.txt actually exists, // just to ensure there is no chance of an IOException being thrown // in the default case. this.loc = FileLocation.create(DEFAULT_FILE_NAME); charSet = defaultEnc == null ? getDefaultEncoding() : defaultEnc; // Ensure that line separator always has a value, even if the file // does not exist (or is the "default" file). This makes life // easier for host applications that want to display this value. setLineSeparator(System.getProperty("line.separator")); } else { load(loc, defaultEnc); // Sets this.loc } if (this.loc.isLocalAndExists()) { File file = new File(this.loc.getFileFullPath()); lastSaveOrLoadTime = file.lastModified(); setReadOnly(!file.canWrite()); } else { lastSaveOrLoadTime = LAST_MODIFIED_UNKNOWN; setReadOnly(false); } setDirty(false); } /** * Callback for when text is inserted into the document. * * @param e * Information on the insertion. */ public void insertUpdate(DocumentEvent e) { if (!dirty) { setDirty(true); } } /** * Returns whether or not the text in this editor has unsaved changes. * * @return Whether or not the text has unsaved changes. */ public boolean isDirty() { return dirty; } /** * Returns whether this file is a local file. * * @return Whether this is a local file. */ public boolean isLocal() { return loc.isLocal(); } /** * Returns whether this is a local file that already exists. * * @return Whether this is a local file that already exists. */ public boolean isLocalAndExists() { return loc.isLocalAndExists(); } /** * Returns whether the text file has been modified outside of this editor since the last load or save operation. * Note that if this is a remote file, this method will always return <code>false</code>. * <p> * * This method may be used by applications to implement a reloading feature, where the user is prompted to reload a * file if it has been modified since their last open or save. * * @return Whether the text file has been modified outside of this editor. * @see #getLastSaveOrLoadTime() */ public boolean isModifiedOutsideEditor() { return loc.getActualLastModified() > getLastSaveOrLoadTime(); } /** * Returns whether or not the text area should be treated as read-only. * * @return Whether or not the text area should be treated as read-only. * @see #setReadOnly(boolean) */ public boolean isReadOnly() { return readOnly; } /** * Loads the specified file in this editor. * * @param loc * The location of the file to load. This cannot be <code>null</code>. * @param defaultEnc * The encoding to use when loading/saving the file. This encoding will only be used if the file is not * Unicode. If this value is <code>null</code>, the system default encoding is used. * @throws IOException * If an IO error occurs. * @see #save() * @see #saveAs(FileLocation) */ public void load(FileLocation loc, String defaultEnc) throws IOException { this.loc = loc; // For new local files, just go with it. if (loc.isLocal() && !loc.isLocalAndExists()) { this.charSet = defaultEnc != null ? defaultEnc : getDefaultEncoding(); return; } // Old local files and remote files, load 'em up. UnicodeReader will // check for BOMs and handle them correctly in all cases, then pass // rest of stream down to InputStreamReader. UnicodeReader ur = new UnicodeReader(loc.getInputStream(), defaultEnc); charSet = ur.getEncoding(); // Remove listener so dirty flag doesn't get set when loading a file. Document doc = getDocument(); doc.removeDocumentListener(this); BufferedReader r = new BufferedReader(ur); try { read(r, null); } finally { doc.addDocumentListener(this); r.close(); } } /** * Reloads this file from disk. The file must exist for this operation to not throw an exception. * <p> * * The file's "dirty" state will be set to <code>false</code> after this operation. If this is a local file, its * "last modified" time is updated to reflect that of the actual file. * <p> * * Note that if the file has been modified on disk, and is now a Unicode encoding when before it wasn't (or if it is * a different Unicode now), this will cause this {@link TextEditorPane}'s encoding to change. Otherwise, the file's * encoding will stay the same. * * @throws IOException * If the file does not exist, or if an IO error occurs reading the file. * @see #isLocalAndExists() */ public void reload() throws IOException { String oldEncoding = getEncoding(); UnicodeReader ur = new UnicodeReader(loc.getInputStream(), oldEncoding); String encoding = ur.getEncoding(); BufferedReader r = new BufferedReader(ur); try { read(r, null); // Dumps old contents. } finally { r.close(); } setEncoding(encoding); setDirty(false); syncLastSaveOrLoadTimeToActualFile(); discardAllEdits(); // Prevent user from being able to undo the reload } /** * Called whenever text is removed from this editor. * * @param e * The document event. */ public void removeUpdate(DocumentEvent e) { if (!dirty) { setDirty(true); } } /** * Saves the file in its current encoding. * <p> * * The text area's "dirty" state is set to <code>false</code>, and if this is a local file, its "last modified" time * is updated. * * @throws IOException * If an IO error occurs. * @see #saveAs(FileLocation) * @see #load(FileLocation, String) */ public void save() throws IOException { saveImpl(loc); setDirty(false); syncLastSaveOrLoadTimeToActualFile(); } /** * Saves this file in a new local location. This method fires a property change event of type * {@link #FULL_PATH_PROPERTY}. * * @param loc * The location to save to. * @throws IOException * If an IO error occurs. * @see #save() * @see #load(FileLocation, String) */ public void saveAs(FileLocation loc) throws IOException { saveImpl(loc); // No exception thrown - we can "rename" the file. String old = getFileFullPath(); this.loc = loc; setDirty(false); lastSaveOrLoadTime = loc.getActualLastModified(); firePropertyChange(FULL_PATH_PROPERTY, old, getFileFullPath()); } /** * Saves the text in this editor to the specified location. * * @param loc * The location to save to. * @throws IOException * If an IO error occurs. */ private void saveImpl(FileLocation loc) throws IOException { OutputStream out = loc.getOutputStream(); PrintWriter w = new PrintWriter( new BufferedWriter(new UnicodeWriter(out, getEncoding()))); try { write(w); } finally { w.close(); } } /** * Sets whether or not this text in this editor has unsaved changes. This fires a property change event of type * {@link #DIRTY_PROPERTY}. * * @param dirty * Whether or not the text has beeen modified. * @see #isDirty() */ private void setDirty(boolean dirty) { if (this.dirty != dirty) { this.dirty = dirty; firePropertyChange(DIRTY_PROPERTY, !dirty, dirty); } } /** * Sets the document for this editor. * * @param doc * The new document. */ public void setDocument(Document doc) { Document old = getDocument(); if (old != null) { old.removeDocumentListener(this); } super.setDocument(doc); doc.addDocumentListener(this); } /** * Sets the encoding to use when reading or writing this file. This method sets the editor's dirty flag when the * encoding is changed. * * @param encoding * The new encoding. * @throws UnsupportedCharsetException * If the encoding is not supported. * @throws NullPointerException * If <code>encoding</code> is <code>null</code>. * @see #getEncoding() */ public void setEncoding(String encoding) { if (encoding == null) { throw new NullPointerException("encoding cannot be null"); } else if (!Charset.isSupported(encoding)) { throw new UnsupportedCharsetException(encoding); } if (charSet == null || !charSet.equals(encoding)) { charSet = encoding; setDirty(true); } } /** * Sets the line separator sequence to use when this file is saved (e.g. "<code>\n</code>", "<code>\r\n</code>" or " * <code>\r</code>"). * * Besides parameter checking, this method is preferred over <code>getDocument().putProperty()</code> because it * sets the editor's dirty flag when the line separator is changed. * * @param separator * The new line separator. * @throws NullPointerException * If <code>separator</code> is <code>null</code>. * @throws IllegalArgumentException * If <code>separator</code> is not one of "<code>\n</code>", "<code>\r\n</code>" or "<code>\r</code>". * @see #getLineSeparator() */ public void setLineSeparator(String separator) { setLineSeparator(separator, true); } /** * Sets the line separator sequence to use when this file is saved (e.g. "<code>\n</code>", "<code>\r\n</code>" or " * <code>\r</code>"). * * Besides parameter checking, this method is preferred over <code>getDocument().putProperty()</code> because can * set the editor's dirty flag when the line separator is changed. * * @param separator * The new line separator. * @param setDirty * Whether the dirty flag should be set if the line separator is changed. * @throws NullPointerException * If <code>separator</code> is <code>null</code>. * @throws IllegalArgumentException * If <code>separator</code> is not one of "<code>\n</code>", "<code>\r\n</code>" or "<code>\r</code>". * @see #getLineSeparator() */ public void setLineSeparator(String separator, boolean setDirty) { if (separator == null) { throw new NullPointerException("terminator cannot be null"); } if (!"\r\n".equals(separator) && !"\n".equals(separator) && !"\r".equals(separator)) { throw new IllegalArgumentException("Invalid line terminator"); } Document doc = getDocument(); Object old = doc.getProperty( RTextAreaEditorKit.EndOfLineStringProperty); if (!separator.equals(old)) { doc.putProperty(RTextAreaEditorKit.EndOfLineStringProperty, separator); if (setDirty) { setDirty(true); } } } /** * Sets whether or not this text area should be treated as read-only. This fires a property change event of type * {@link #READ_ONLY_PROPERTY}. * * @param readOnly * Whether or not the document is read-only. * @see #isReadOnly() */ public void setReadOnly(boolean readOnly) { if (this.readOnly != readOnly) { this.readOnly = readOnly; firePropertyChange(READ_ONLY_PROPERTY, !readOnly, readOnly); } } /** * Syncs this text area's "last saved or loaded" time to that of the file being edited, if that file is local and * exists. If the file is remote or is local but does not yet exist, nothing happens. * <p> * * You normally do not have to call this method, as the "last saved or loaded" time for {@link TextEditorPane}s is * kept up-to-date internally during such operations as {@link #save()}, {@link #reload()}, etc. * * @see #getLastSaveOrLoadTime() * @see #isModifiedOutsideEditor() */ public void syncLastSaveOrLoadTimeToActualFile() { if (loc.isLocalAndExists()) { lastSaveOrLoadTime = loc.getActualLastModified(); } } }