/* Alloy Analyzer 4 -- Copyright (c) 2006-2009, Felix Chang
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
* (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
* merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
* OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package edu.mit.csail.sdg.alloy4;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.event.ActionEvent;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.io.File;
import java.util.Collection;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.border.EmptyBorder;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.AbstractDocument;
import javax.swing.text.BoxView;
import javax.swing.text.Document;
import javax.swing.text.Element;
import javax.swing.text.StyledEditorKit;
import javax.swing.text.View;
import javax.swing.text.ViewFactory;
import edu.mit.csail.sdg.alloy4.Listener.Event;
/** Graphical syntax-highlighting editor.
*
* <p><b>Thread Safety:</b> Can be called only by the AWT event thread
*/
public final class OurSyntaxWidget {
/** The current list of listeners; possible events are { STATUS_CHANGE, FOCUSED, CTRL_PAGE_UP, CTRL_PAGE_DOWN, CARET_MOVED }. */
public final Listeners listeners = new Listeners();
/** The JScrollPane containing everything. */
private final JScrollPane component = OurUtil.make(new JScrollPane(), new EmptyBorder(0, 0, 0, 0));
/** This is an optional JComponent annotation. */
public final JComponent obj1;
/** This is an optional JComponent annotation. */
public final JComponent obj2;
/** The underlying StyledDocument being displayed (changes will trigger the STATUS_CHANGE event) */
private final OurSyntaxUndoableDocument doc = new OurSyntaxUndoableDocument("Monospaced", 14);
/** The underlying JTextPane being displayed. */
private final JTextPane pane = OurAntiAlias.pane(Color.BLACK, Color.WHITE, new EmptyBorder(1, 1, 1, 1));
/** The filename for this JTextPane (changes will trigger the STATUS_CHANGE event) */
private String filename = "";
/** Whether this JTextPane has been modified since last load/save (changes will trigger the STATUS_CHANGE event) */
private boolean modified;
/** Whether this JTextPane corresponds to an existing disk file (changes will trigger the STATUS_CHANGE event) */
private boolean isFile;
/** Caches the most recent background painter if nonnull. */
private OurHighlighter painter;
/** Constructs a syntax-highlighting widget. */
public OurSyntaxWidget() { this(true, "", "Monospaced", 14, 4, null, null); }
/** Constructs a syntax-highlighting widget. */
@SuppressWarnings("serial")
public OurSyntaxWidget
(boolean enableSyntax, String text, String fontName, int fontSize, int tabSize, JComponent obj1, JComponent obj2) {
this.obj1 = obj1;
this.obj2 = obj2;
final OurSyntaxWidget me = this;
final ViewFactory defaultFactory = (new StyledEditorKit()).getViewFactory();
doc.do_enableSyntax(enableSyntax);
doc.do_setFont(fontName, fontSize, tabSize);
pane.setEditorKit(new StyledEditorKit() { // Prevents line-wrapping up to width=30000, and tells it to use our Document obj
@Override public Document createDefaultDocument() { return doc; }
@Override public ViewFactory getViewFactory() {
return new ViewFactory() {
public View create(Element x) {
if (!AbstractDocument.SectionElementName.equals(x.getName())) return defaultFactory.create(x);
return new BoxView(x, View.Y_AXIS) { // 30000 is a good width to use here; value > 32767 appears to cause errors
@Override public final float getMinimumSpan(int axis) { return super.getPreferredSpan(axis); }
@Override public final void layout(int w, int h) { try {super.layout(30000, h);} catch(Throwable ex) {} }
};
}
};
}
});
if (text.length()>0) { pane.setText(text); pane.setCaretPosition(0); }
doc.do_clearUndo();
pane.getActionMap().put("alloy_copy", new AbstractAction("alloy_copy") {
public void actionPerformed(ActionEvent e) { pane.copy(); }
});
pane.getActionMap().put("alloy_cut", new AbstractAction("alloy_cut") {
public void actionPerformed(ActionEvent e) { pane.cut(); }
});
pane.getActionMap().put("alloy_paste", new AbstractAction("alloy_paste") {
public void actionPerformed(ActionEvent e) { pane.paste(); }
});
pane.getActionMap().put("alloy_ctrl_pageup", new AbstractAction("alloy_ctrl_pageup") {
public void actionPerformed(ActionEvent e) { listeners.fire(me, Event.CTRL_PAGE_UP); }
});
pane.getActionMap().put("alloy_ctrl_pagedown", new AbstractAction("alloy_ctrl_pagedown") {
public void actionPerformed(ActionEvent e) { listeners.fire(me, Event.CTRL_PAGE_DOWN); }
});
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_MASK), "alloy_copy");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_X, InputEvent.CTRL_MASK), "alloy_cut");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_MASK), "alloy_paste");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, InputEvent.CTRL_MASK), "alloy_copy");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, InputEvent.SHIFT_MASK), "alloy_paste");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, InputEvent.SHIFT_MASK), "alloy_cut");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_PAGE_UP, InputEvent.CTRL_MASK), "alloy_ctrl_pageup");
pane.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_PAGE_DOWN, InputEvent.CTRL_MASK), "alloy_ctrl_pagedown");
doc.addDocumentListener(new DocumentListener() {
public void insertUpdate(DocumentEvent e) { modified=true; listeners.fire(me, Event.STATUS_CHANGE); }
public void removeUpdate(DocumentEvent e) { modified=true; listeners.fire(me, Event.STATUS_CHANGE); }
public void changedUpdate(DocumentEvent e) { }
});
pane.addFocusListener(new FocusAdapter() {
@Override public void focusGained(FocusEvent e) { listeners.fire(me, Event.FOCUSED); }
});
pane.addCaretListener(new CaretListener() {
public void caretUpdate(CaretEvent e) { listeners.fire(me, Event.CARET_MOVED); }
});
component.addFocusListener(new FocusAdapter() {
@Override public void focusGained(FocusEvent e) { pane.requestFocusInWindow(); }
});
component.setFocusable(false);
component.setMinimumSize(new Dimension(50, 50));
component.setViewportView(pane);
modified = false;
}
/** Add this object into the given container. */
public void addTo(JComponent newParent, Object constraint) { newParent.add(component, constraint); }
/** Returns true if this textbox is currently shaded. */
boolean shaded() { return pane.getHighlighter().getHighlights().length > 0; }
/** Remove all shading. */
void clearShade() { pane.getHighlighter().removeAllHighlights(); }
/** Shade the range of text from start (inclusive) to end (exclusive). */
void shade(Color color, int start, int end) {
int c = color.getRGB() & 0xFFFFFF;
if (painter==null || (painter.color.getRGB() & 0xFFFFFF)!=c) painter = new OurHighlighter(color);
try { pane.getHighlighter().addHighlight(start, end, painter); } catch(Throwable ex) { } // exception is okay
}
/** Returns the filename. */
public String getFilename() { return filename; }
/** Returns the modified-or-not flag. */
public boolean modified() { return modified; }
/** Returns whether this textarea is based on an actual disk file. */
public boolean isFile() { return isFile; }
/** Changes the font name, font size, and tab size for the document. */
void setFont(String fontName, int fontSize, int tabSize) { if (doc!=null) doc.do_setFont(fontName, fontSize, tabSize); }
/** Enables or disables syntax highlighting. */
void enableSyntax(boolean flag) { if (doc!=null) doc.do_enableSyntax(flag); }
/** Return the number of lines represented by the current text (where partial line counts as a line).
* <p> For example: count("")==1, count("x")==1, count("x\n")==2, and count("x\ny")==2
*/
public int getLineCount() { return doc.do_getLineCount(); }
/** Return the starting offset of the given line (If "line" argument is too large, it will return the last line's starting offset)
* <p> For example: given "ab\ncd\n", start(0)==0, start(1)==3, start(2...)==6. Same thing when given "ab\ncd\ne".
*/
public int getLineStartOffset(int line) { return doc.do_getLineStartOffset(line); }
/** Return the line number that the offset is in (If "offset" argument is too large, it will just return do_getLineCount()-1).
* <p> For example: given "ab\ncd\n", offset(0..2)==0, offset(3..5)==1, offset(6..)==2. Same thing when given "ab\ncd\ne".
*/
public int getLineOfOffset(int offset) { return doc.do_getLineOfOffset(offset); }
/** Returns true if we can perform undo right now. */
public boolean canUndo() { return doc.do_canUndo(); }
/** Returns true if we can perform redo right now. */
public boolean canRedo() { return doc.do_canRedo(); }
/** Perform undo if possible. */
public void undo() { int i = doc.do_undo(); if (i>=0 && i<=pane.getText().length()) moveCaret(i, i); }
/** Perform redo if possible. */
public void redo() { int i = doc.do_redo(); if (i>=0 && i<=pane.getText().length()) moveCaret(i, i); }
/** Clear the undo history. */
public void clearUndo() { doc.do_clearUndo(); }
/** Return the caret position. */
public int getCaret() { return pane.getCaretPosition(); }
/** Select the content between offset a and offset b, and move the caret to offset b. */
public void moveCaret(int a, int b) {
try { pane.setCaretPosition(a); pane.moveCaretPosition(b); } catch(Exception ex) { if (a!=0 || b!=0) moveCaret(0, 0); }
}
/** Return the entire text. */
public String getText() { return pane.getText(); }
/** Change the entire text to the given text (and sets the modified flag) */
public void setText(String text) { pane.setText(text); }
/** Copy the current selection into the clipboard. */
public void copy() { pane.copy(); }
/** Cut the current selection into the clipboard. */
public void cut() { pane.cut(); }
/** Paste the current clipboard content. */
public void paste() { pane.paste(); }
/** Discard all; if askUser is true, we'll ask the user whether to save it or not if the modified==true.
* @return true if this text buffer is now a fresh empty text buffer
*/
boolean discard(boolean askUser, Collection<String> bannedNames) {
char ans = (!modified || !askUser) ? 'd' : OurDialog.askSaveDiscardCancel("The file \"" + filename + "\"");
if (ans=='c' || (ans=='s' && save(false, bannedNames)==false)) return false;
for(int i=1; ;i++) if (!bannedNames.contains(filename = Util.canon("Untitled " + i + ".als"))) break;
pane.setText(""); clearUndo(); modified=false; isFile=false; listeners.fire(this, Event.STATUS_CHANGE);
return true;
}
/** Discard current content then read the given file; return true if the entire operation succeeds. */
boolean load(String filename) {
String x;
try {
x = Util.readAll(filename);
} catch(Throwable ex) { OurDialog.alert("Error reading the file \"" + filename + "\""); return false; }
pane.setText(x); moveCaret(0,0); clearUndo(); modified=false; isFile=true; this.filename=filename;
listeners.fire(this, Event.STATUS_CHANGE);
return true;
}
/** Discard (after confirming with the user) current content then reread from disk file. */
void reload() {
if (!isFile) return; // "untitled" text buffer does not have a on-disk file to refresh from
if (modified && !OurDialog.yesno("You have unsaved changes to \"" + filename + "\"\nAre you sure you wish to discard "
+ "your changes and reload it from disk?", "Discard your changes", "Cancel this operation")) return;
String t;
try { t=Util.readAll(filename); } catch(Throwable ex) { OurDialog.alert("Cannot read \""+filename+"\""); return; }
if (modified==false && t.equals(pane.getText())) return; // no text change nor status change
int c=pane.getCaretPosition(); pane.setText(t); moveCaret(c,c); modified=false; listeners.fire(this, Event.STATUS_CHANGE);
}
/** Save the current tab content to the file system, and return true if successful. */
boolean saveAs(String filename, Collection<String> bannedNames) {
filename = Util.canon(filename);
if (bannedNames.contains(filename)) {
OurDialog.alert("The filename \""+filename+"\"\nis already open in another tab.");
return false;
}
try { Util.writeAll(filename, pane.getText()); }
catch (Throwable e) { OurDialog.alert("Error writing to the file \""+filename+"\""); return false; }
this.filename = Util.canon(filename); // a new file's canonical name may change
modified=false; isFile=true; listeners.fire(this, Event.STATUS_CHANGE); return true;
}
/** Save the current tab content to the file system and return true if successful.
* @param alwaysPickNewName - if true, it will always pop up a File Selection dialog box to ask for the filename
*/
boolean save(boolean alwaysPickNewName, Collection<String> bannedNames) {
String n = this.filename;
if (alwaysPickNewName || isFile==false || n.startsWith(Util.jarPrefix())) {
File f = OurDialog.askFile(false, null, ".als", ".als files"); if (f==null) return false;
n = Util.canon(f.getPath()); if (f.exists() && !OurDialog.askOverwrite(n)) return false;
}
if (saveAs(n, bannedNames)) {Util.setCurrentDirectory(new File(filename).getParentFile()); return true;} else return false;
}
/** Transfer focus to this component. */
public void requestFocusInWindow() { if (pane!=null) pane.requestFocusInWindow(); }
}