Developing a File Browser in JavaFX[]
Introduction[]
This document illustrates the development of a simple text file browser implemented in JavaFX. Starting with the basics of creating windows and displaying text the application is incrementally refined until it implements its expected functionality.
It's assumed the reader is familiar with the Java programming language
as well as with the basics of JavaFX Script ([Getting Started]).
About File Browsing[]
This section describes the expected functionality of our (minimalist) file browser.
A file browser allows the user to select and open text files to be
displayed in a window. For this, our example browser presents the user
an Open
menu option that looks like:
Selecting this menu item causes a file selection dialog to appear:
Once a file is selected its contents are displayed in the browser's text area:
As seen in the above snapshot, the selected file contains long lines
that don't fit properly in the window. The Line Wrap item
of the View
menu causes long lines to be folded
like in:
Here, however, words are truncated along lines in a less than
readable manner. The Word Wrap item of the
View
menu causes truncated words to be wrapped like
in:
Finally, we'd like our application to keep a list of the recently browsed files so that it's possible to return to them without using the file selection dialog:
The remaining of this document presents a step-by-step process in which the described functionality is implemented as an JavaFX script.
Displaying Windows and Text[]
Before we implement file browsing logic as such, we'll see how to display windows and constant text in JavaFX.
If we want to display a simple window containing fixed text like:
then the JavaFX code required is:
package browser;
import javafx.ui.Frame;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.lang.System;
Frame {
width: 550
height: 350
visible: true
title: 'Lorem Ipsum'
onClose: operation() { System.exit(0); }
content: RootPane {
content: TextArea {
text:
"Lorem ipsum dolor sit amet, consectetur adipisicing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident, sunt in culpa qui officia
deserunt mollit anim id est laborum."
}
}
};
Let's dissect this code.
The package
Directive[]
In the statement:
package browser;
the package
directive defines the namespace within which
the JavaFX compilation unit exists. The notion of package in JavaFX is very
close to that of Java.
Unlike Java, though, an JavaFX compilation unit is not limited to
defining only one class per source file. JavaFX source files can declare
several classes as well as top-level functions, operations and global
code and variables. As in all scripting languages, top-level global
code is executed inmmediately upon interpretation.
The import
Directive[]
import javafx.ui.Frame;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.lang.System;
By convention, classes under the f3
root package
correspond to language-supplied classes. This is
equivalent to the java
root package in the Java
programming language.
In general, the import
statement is equivalent in
meaning to their Java counterpart. For classes to be used without
package qualification they must be explicitly imported. Otherwise
class names must be fully qualified like in:
<<javafx.ui.Frame>> {
width: 550
height: 350
onClose: operation() { <<java.lang.System>>.exit(0); }
// snip...
}
Java classes are imported using the same syntax and semantics of
JavaFX classes. In general, there's no runtime distinction between
JavaFX and Java classes and objects.
Note, however, that Java classes under the java.lang
package (such as java.lang.System
) still need to
explicitly imported; they're not implicitly imported as is the
case in Java. Likewise, no f3
package is implicitly
imported.
The Applications's Main Frame
[]
Most JavaFX GUI applications declare one Frame
object
literal that corresponds to the top-level window of the GUI:
Frame {
width:550
height: 350
visible: true
title: 'Lorem Ipsum'
onClose: operation() { System.exit(0); }
content: RootPane {
content: TextArea {
text:
"Lorem ipsum dolor sit amet, consectetur adipisicing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident, sunt in culpa qui officia
deserunt mollit anim id est laborum."
}
}
};
In the above snippet the width
and height
attributes determine the size of the window, while the
title
attribute determines the text used as the window's
caption.
The onClose
operation is an attribute of type
operation()
. Class operations are a functional
construct allowing the embeding of event handling code in object
literal attributes. This simple and readable mechanism replaces the
tedious listener or event handler interface implementation required
by conventional Swing programming.
Frames require a container as their content. In our case we've
chosen a top-level RootPane
container.
RootPane
, in turn, requires one or more JavaFX widgets as
its content. In order to keep our example simple, we've chosen a
TextArea
widget to display our fixed text.
Notice that the string constant containing our sample text spans
several lines. Unlike Java, an JavaFX string constant can contain
newlines.
JavaFX string constants can also contain embedded expressions that are
evaluated at runtime. Such expressions are enclosed in curly braces
and can nest:
"The current date is {new <<java.util.Date>>()}."
Adding a Menu[]
The next step is to add a menu to our application. Since no real
browser functionality is yet in place, a File
menu will
be created with a single action: Exit
. The application
would then look like:
The JavaFX code is now:
package browser;
import javafx.ui.Frame;
import javafx.ui.Menu;
import javafx.ui.MenuBar;
import javafx.ui.MenuItem;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.lang.System;
Frame {
width: 550
height: 350
visible: true
title: 'Lorem Ipsum'
onClose: operation() { System.exit(0); }
content: RootPane {
menubar: MenuBar {
menus: Menu {
text: "File"
mnemonic: F
items: MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() { System.exit(0); }
}
}
}
content: TextArea {
text:
"Lorem ipsum dolor sit amet, consectetur adipisicing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident, sunt in culpa qui officia
deserunt mollit anim id est laborum."
}
}
};
A MenuBar
has been added as an attribute of the
RootPane
container. A MenuBar
may
have one or more Menu
s each having one or more
MenuItem
s.
In our case we've added a single File
menu with a
single Exit
menu item.
Note that the accelerator keystroke combination is Ctrl-Q
instead of Ctrl-X
as the menu item mnemonic would
suggest. This is due to the fact that, in most environments,
Ctrl-X
is reserved for the cut
clipboard operation.
The action
operation attribute is the event handler to be
invoked when the menu item is clicked on or is activated via the
mnemonic or accelerator keys. As seen, it simply calls
System.exit(0)
to terminate the application.
A Simple GUI Application Pattern[]
Before we go on to adding more functionality to our application, let's discuss a common pattern recurring in simple JavaFX GUI applications. Understanding this pattern will enable us to add the missing functionality to our application. It's worth noting, however, that this is just a single pattern among many, more complex ones commonly used in JavaFX.
A simple JavaFX GUI application consists of:
A model class whose attributes correspond to data items used by the GUI
A single model class instance referenced and used by the GUI
A Frame
object literal embodying the GUI
as such
Thus, a simplified view of our file browser application would look
like:
// The model class...
class BrowserModel {
attribute fileName: String;
attribute fileContents: String;
operation loadFile(file: File);
}
// snip...
// The model instance
var model = new BrowserModel;
// The main GUI frame
Frame {
width: 550
height: 350
visible: true
title: 'Browser'
onClose: operation() { System.exit(0); }
content: RootPane {
menubar: MenuBar {
// snip...
}
content: TextArea {
text: bind model.fileContents // The GUI references the model instance
}
}
};
For simple applications this pattern occurs once at the script top-level. For more complex applications, though, it is possible to have multiple frames sharing the same or different models.
The Model Class[]
The structure of the model class is determined by what data items are used or referenced by the GUI.
In our case, we want the browser to display the file name on
its window title. We obviously want to display the
file contents, too. Finally, we'll also need a means of
loading file contents given a java.io.File
object.
Thus, our model class will be:
class BrowserModel {
attribute fileName: String; // To display in the window caption
attribute contents: String; // To display in the text area
operation loadFile(file: File); // To load file contents
}
The implementation of the loadFile
operation is
straightforward:
operation BrowserModel.loadFile(file: File) {
var reader = new BufferedReader(new FileReader(file));
var builder = new StringBuilder();
while (true) {
var line = reader.readLine();
if (line == null) {
break;
}
builder.append(line);
builder.append('\n');
}
reader.close();
this.fileName = file.canonicalPath;
this.contents = builder.toString();
}
Note how fileName
is assigned from the file's
canonicalPath
attribute rather than from its
getCanonicalPath()
method.
This is possible because, in JavaFX, Java bean properties can be
referenced as JavaFX-style attributes.
The Model Instance[]
Instantiating the model instance is trivial
var model = new BrowserModel;
For our simple example, there is only one model instance for the entire GUI application. This single instance is subsequently referenced and used inside the GUI frame in both declarative and procedural ways.
The GUI Frame[]
The GUI frame is actually an object literal whose nesting structure mirrors that of the GUI visual appearance. For our simple browser the GUI frame is:
Frame {
var: win
width: 800
height: 700
visible: true
title: bind "{model.fileName}"
onClose: operation() { System.exit(0); }
content: RootPane {
menubar: MenuBar {
menus: [
Menu {
text: "File"
mnemonic: F
items: bind [
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
MenuSeparator {},
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() {
System.exit(0);
}
},
]
},
]
}
content: TextArea {
text: bind model.contents
}
}
};
The bind
Operator[]
bind
is a powerful and commonly used JavaFX construct.
Let's take a look at the frame's title
declaration:
title: bind "{model.fileName}"
This means that the window's caption will contain whatever the
value of the "{model.fileName}"
expression is.
Thus, if the file name is changed programmatically, the window title
will be automatically updated without the need to write event handlers
or synchronization logic.
In general, when a variable or initializer is bound to an expression
its value will automatically change whenever any of the variables
participating in the expression changes. This is akin to
spreadsheet formula recalculation. Bindings may be declared as
lazy
in which case recalculation takes place only
when the bound variable value is requested.
In our example bind
is also used to provide a value for
the text area's text
content:
content: TextArea {
text: bind model.contents
}
This is a special case of bind
where the right-hand
part of the bind is a class attribute rather than an expression.
In this case, the binding is bidirectional: any change
in model.contents
will be automatically reflected
in the text area's text
attribute and, correspondingly,
any change in the text area's text
attribute will be
automatically reflected in model.contents
.
For input-capable widgets such as text fields, text areas or check and
radio buttons this is the basic mechanism used to propagate user input
to the model's data.
The var
pseudo-attribute[]
Note how the frame object literal has an attribute called
var
:
Frame {
var: win
width: 800
height: 700
visible: true
// snip...
}
This pseudo-attribute does not correspond to any "real" attribute
defined for class Frame
. Instead, it introduces a local
variable that points to the frame object being populated. This variable
is visible only inside the frame's object literal and can be used
whenever a reference to the object is needed. Of course, the
var
pseudo-attribute can be used for any object type in
an object literal (not just for Frame
).
In general, function, operation and variable declarations are allowed
between attribute initializers. Such program elements are visible only
after their declaration and only inside their enclosing object literal
block.
In our example, the frame reference is needed to show the open file
dialog, an operation that requires specifying the associated frame.
This can be seen in the Open
menu item:
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
Here, the action associated with menu item Open
creates a FileChooser
widget and displays it
specifying the current frame (var: win
) as its owner
window.
Notice, by the way, that the FileChooser
own action
is to invoke the loadFile
operation on the model instance.
This operation changes the value of model.fileName
to which
the frame title is bound thereby causing the window's caption to be
automatically updated.
First Working Version[]
We have now a working version of our minimalist file browser. The complete source code is:
import javafx.ui.FileChooser;
import javafx.ui.Frame;
import javafx.ui.Menu;
import javafx.ui.MenuBar;
import javafx.ui.MenuItem;
import javafx.ui.MenuSeparator;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.lang.StringBuilder;
import java.lang.System;
class BrowserModel {
attribute fileName: String;
attribute contents: String;
operation loadFile(file: File);
}
operation BrowserModel.loadFile(file: File) {
var reader = new BufferedReader(new FileReader(file));
var builder = new StringBuilder();
while (true) {
var line = reader.readLine();
if (line == null) {
break;
}
builder.append(line);
builder.append('\n');
}
reader.close();
this.fileName = file.canonicalPath;
this.contents = builder.toString();
}
var model = new BrowserModel;
Frame {
var: win
width: 550
height: 350
visible: true
title: bind "{model.fileName}"
onClose: operation() { System.exit(0); }
content: RootPane {
menubar: MenuBar {
menus: [
Menu {
text: "File"
mnemonic: F
items: bind [
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
MenuSeparator {},
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() {
System.exit(0);
}
},
]
},
]
}
content: TextArea {
text: bind model.contents
}
}
};
The initial, empty window will look like:
We have added a menu separator between the Open
and
Exit
menu items. Thus, upon activating the menu,
the window will look like:
After selecting the Open
menu item, a file
chooser dialog is displayed that allows the user to select what
file to browse:
Once the file has been selected its contents are displayed in the frame's text area:
A Few Improvements[]
We now introduce some improvements to our file browser application.
Exiting the Application[]
Note that the onClose
frame attribute and the
Exit
menu item both invoke
System.exit(0)
. This redundancy becomes inconvenient when
it's necessary to execute wrapup logic prior to exiting the
application. Such logic is better centralized in a single location.
Thus, a better way to structure this is to add an exit()
operation to the BrowserModel
class as follows:
class BrowserModel {
attribute fileName: String;
attribute contents: String;
operation openFile(file: File);
operation exit();
}
operation BrowserModel.exit() {
// Any wrapup logic would go here
System.exit(0);
}
Given this operation, the event handler and the menu item can now
simply invoke the exit()
operation on the model instance:
Frame {
var: win
width: 800
height: 700
visible: true
onClose: operation() { model.exit(); }
// Snip...
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() { model.exit(); }
},
// Snip..
};
Avoiding Content Modification[]
By default, a TextArea
allows user input on its text
content. Since our application is a read-only browser -not an
editor- the user may be confused because she would be able to
modify text and, therefore, would also expect to be able to save
changes. In order to avoid such confusion it's necessary to disable
user input:
content: TextArea {
editable: false
text: bind model.contents
}
Showing the Application Title[]
It's customary for browsers to have a window caption that shows
the name of the application plus the name of the file being
displayed. For this we modify our title
frame property
as follows:
Frame {
var: win
width: 550
height: 350
visible: true
onClose: operation() { model.exit(); }
function makeTitle(fileName: String) =
"JavaFX Browser {if fileName <> null and fileName.length() <> 0 then " - {fileName}" else ''}";
title: bind makeTitle(model.fileName)
// Snip...
};
Notice that the declaration of function makeTitle
is
inlined in the Frame
object literal. This is another
case in which an inline declaration is made between property
initializers. As usual, the declared function is visible only
after its declaration and only inside its enclosing object literal
block.
In our case, function makeTitle
is declared as a simple
string expression that generates the application name
(JavaFX Browser) plus an optional dash and the file name if it's
set.
Despite its procedural appearance, the
if a then b else c
expression is actually equivalent to Java's
a ? b : c.
Thus, inside expressions, if
is a ternary operator, not a
flow control directive.
It's also worth noting that in the title
string
expression there is a nested substitution expression
({filename}
).
Revised Code[]
The following program listing shows our code after adding these improvements. Changes introduced with respect to the previous version are shown in bold.
package browser;
import javafx.ui.FileChooser;
import javafx.ui.Frame;
import javafx.ui.Menu;
import javafx.ui.MenuBar;
import javafx.ui.MenuItem;
import javafx.ui.MenuSeparator;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.lang.StringBuilder;
import java.lang.System;
class BrowserModel {
attribute fileName: String;
attribute contents: String;
operation loadFile(file: File);
operation exit();
}
operation BrowserModel.exit() {
System.exit(0);
}
operation BrowserModel.loadFile(file: File) {
var reader = new BufferedReader(new FileReader(file));
var builder = new StringBuilder();
while (true) {
var line = reader.readLine();
if (line == null) {
break;
}
builder.append(line);
builder.append('\n');
}
reader.close();
this.fileName = file.canonicalPath;
this.contents = builder.toString();
}
var model = new BrowserModel;
Frame {
var: win
width: 800
height: 700
visible: true
onClose: operation() { model.exit(); }
function makeTitle(fileName: String) =
"Browser {if fileName <> null and fileName.length() <> 0 then " - {fileName}" else ''}";
title: bind makeTitle(model.fileName)
content: RootPane {
menubar: MenuBar {
menus: [
Menu {
text: "File"
mnemonic: F
items: bind [
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
MenuSeparator {},
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() {
model.exit();
}
},
]
},
]
}
content: TextArea {
editable: false
text: bind model.contents
}
}
};
This is a good moment to ponder how simple and expressive JavaFX is for building GUI's. Our working file browser weighs a mere 100 lines of code practically all of which is declarative. Contrast this with an equivalent Swing application.
Adding a View Menu[]
A View
menu provides options to control the display
of file contents inside the text area:
Adding a view menu involves extending class BrowserModel
to add attributes corresponding to the word wrap and
line wrap features of TextArea
:
class BrowserModel {
attribute fileName: String;
attribute contents: String;
attribute wordWrap: Boolean;
attribute lineWrap: Boolean;
operation loadFile(file: File);
operation exit();
}
Correspondingly, the TextArea
object literal must be
extended to bind to these new attributes.
content: TextArea {
editable: false
wrapStyleWord: bind model.wordWrap
lineWrap: bind model.lineWrap
text: bind model.contents
}
Finally, a new menu must be added to allow the user to control the wrapping settings:
Menu {
text: "View"
mnemonic: E
items: [
CheckBoxMenuItem {
text: "Word Wrap"
mnemonic: W
selected: bind model.wordWrap
},
CheckBoxMenuItem {
text: "Line Wrap"
mnemonic: L
selected: bind model.lineWrap
},
]
},
It's interesting to see how bind
works behind the scenes
to synchronize program state. Let's consider the following scenario:
The user checks the menu item labeled Line Wrap
. This
changes the menu item's selected
attribute from
false
to true
. A check mark is placed
to the left of the menu item's label.
The menu item's selected
attribute is bound
to the model instance lineWrap
attribute in a
bidirectional fashion. Therefore when the value of
selected
changes in response to user input,
the value of attribute lineWrap
in the model
instance changes also to true
.
The attribute lineWrap
of the browser's
TextArea
is, in turn, bidirectionally bound to the
model instance lineWrap
attribute. Thus, when
the model instance attribute changes in response to the menu item
change, the change propagates to the lineWrap
attribute of the text area.
The change in attribute lineWrap
in the text area
eventually propagates to the underlying Swing JTextArea
component. This results in an immediate visual change where
long lines are folded.
Contrast this with conventional Swing programming where procedural
event handlers must be written to handle and propagate state change.
The following program listing shows our code after adding the view
menu. Changes introduced with respect to the previous version are
shown in bold.
package browser;
import javafx.ui.CheckBoxMenuItem;
import javafx.ui.FileChooser;
import javafx.ui.Frame;
import javafx.ui.Menu;
import javafx.ui.MenuBar;
import javafx.ui.MenuItem;
import javafx.ui.MenuSeparator;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.lang.StringBuilder;
import java.lang.System;
class BrowserModel {
attribute fileName: String;
attribute contents: String;
attribute wordWrap: Boolean;
attribute lineWrap: Boolean;
operation loadFile(file: File);
operation exit();
}
operation BrowserModel.exit() {
System.exit(0);
}
operation BrowserModel.loadFile(file: File) {
var reader = new BufferedReader(new FileReader(file));
var builder = new StringBuilder();
while (true) {
var line = reader.readLine();
if (line == null) {
break;
}
builder.append(line);
builder.append('\n');
}
reader.close();
this.fileName = file.canonicalPath;
this.contents = builder.toString();
}
var model = new BrowserModel;
Frame {
var: win
width: 800
height: 700
visible: true
onClose: operation() { model.exit(); }
function makeTitle(fileName: String) =
"Browser {if fileName <> null and fileName.length() <> 0 then " - {fileName}" else ''}";
title: bind makeTitle(model.fileName)
content: RootPane {
menubar: MenuBar {
menus: [
Menu {
text: "File"
mnemonic: F
items: bind [
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
MenuSeparator {},
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() {
model.exit();
}
},
]
},
Menu {
text: "View"
mnemonic: E
items: [
CheckBoxMenuItem {
text: "Word Wrap"
mnemonic: W
selected: bind model.wordWrap
},
CheckBoxMenuItem {
text: "Line Wrap"
mnemonic: L
selected: bind model.lineWrap
},
]
},
]
}
content: TextArea {
editable: false
wrapStyleWord: bind model.wordWrap
lineWrap: bind model.lineWrap
text: bind model.contents
}
}
};
Adding the view menu fattened our code size to 121 lines, 99% of which correspond to declarative, non-procedural code. Notice also how the containment structure of this declarative code mirrors the visual appearance and the widget composition of the GUI.
Adding a List of Recently Opened Files[]
Our last goal is to maintain a dynamic set of menu items under
the File
menu where each menu item corresponds to a
previously viewed file:
To maintain a list of recently opened files we extend the model
class to add a recentFileNames
array attribute.
class BrowserModel {
attribute fileName: String;
attribute fileContents: String;
attribute recentFileNames: String*;
attribute lineWrap: Boolean;
attribute wordWrap: Boolean;
operation loadFile(file: File);
operation exit();
}
A file name is added to the list of recently opened files
whenever a new file is opened and the previous one abandoned.
The context appropriate to capture this event is in the
fileName
attribute value change trigger:
attribute BrowserModel.fileName = null;
trigger on BrowserModel.fileName[oldValue] = newValue {
var i:Number;
if (oldValue <> null and oldValue <> newValue) {
delete recentFileNames[i | i == newValue];
insert oldValue as first into recentFileNames;
delete recentFileNames[i | indexof i > 8];
}
}
Here, we initialize the fileName
attribute value
to null
implying no file has been opened so far.
Whenever a file is loaded the value of the fileName
attribute changes and the above trigger fires.
The fileName
Trigger[]
The aliases chosen for the previous and current value of
fileName
are oldValue
and
newValue
respectively.
The logic inside trigger on fileName
change executes only
if:
At least one file has been previously opened. This is the case when
fileName
is not null.
The new file being opened is not the same currently open file
if (oldValue <> null and oldValue <> newValue)
If these conditions hold, the first step is to remove the current file from the list of recently viewed files. This prevents the file from appearing in the menu more than once when it has been visited several times:
delete recentFileNames[. == newValue];
In the list of recently viewed files the most recent one will be the
first in the menu list. For this reason, its name must be added to the
recently viewed file array as first
:
insert oldValue as first into recentFileNames;
If the resulting array has more than 9 elements the excess element is deleted in a least-recently-used fashion:
delete recentFileNames[indexof . > 8];
The reason why we limit the size of the recently viewed files to 9
elements is that we intend to use keystrokes _1
through
_9
as file menu mnemonics.
The Dynamic Menu[]
The following listing shows how the File
menu has been extended to include a dynamic list of file names:
import javafx.ui.KeyStroke;
// Snip...
Menu {
var keyStrokes:KeyStroke = [_1,_2,_3,_4,_5,_6,_7,_8,_9]
text: "File"
mnemonic: F
items: bind [
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
if sizeof model.recentFileNames > 0
then MenuSeparator {}
else [],
foreach (f in model.recentFileNames)
MenuItem {
mnemonic: bind keyStrokes[indexof f]
text: bind "{indexof f + 1} {f}"
action: operation() {
model.loadFile(new File(f));
}
},
MenuSeparator {},
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() { model.exit(); }
},
]
},
A MenuSeparator
is inserted only if the model instance's
recentFileNames
is not empty. This is true only when at
least 2 files have been opened:
if sizeof model.recentFileNames > 0
then MenuSeparator {}
else [],
Note how object literals can be populated conditionally. Here,
the MenuSeparator
may appear or disappear
subject to a runtime condition. Conditional object literal fragments
may affect the GUI structure or appearance in response to changes to
the value of variables used in a conditional expression.
The key to dynamically generating menu items is JavaFX's powerful
foreach
construct:
foreach (f in model.recentFileNames)
MenuItem {
mnemonic: bind keyStrokes[indexof f]
text: bind "{indexof f + 1} {f}"
action: operation() {
model.loadFile(new File(f));
}
},
foreach
traverses an array and yields another array
each of whose elements is built from its body template. In this case,
the template to be expanded on each iteration is a MenuItem
object literal. In our example, the iteration variable to be referenced
inside the template is f
.
Notice that the mnemonic
and text
attributes of each MenuItem
are bound to
(rather than just assigned from) expressions involving the iteration
variable f
. This ensures that whenever the
model.recentFileNames
array changes the whole collection
of menu items is rebuilt.
model.recentFileNames
changes inside the
BrowserModel.fileName
trigger presented above. Attribute
Browser.fileName
, in turn, changes whenever a new file is
loaded. The net effect is that if a new file is selected for viewing
then the File
menu is dyamically rebuilt to include
the new list of recently viewed files. All this without intervening
procedural logic.
The Complete Application[]
In 147 lines of declarative code our application has now achieved its intended functionality. The final code is:
package browser;
import javafx.ui.CheckBoxMenuItem;
import javafx.ui.FileChooser;
import javafx.ui.Frame;
import javafx.ui.KeyStroke;
import javafx.ui.Menu;
import javafx.ui.MenuBar;
import javafx.ui.MenuItem;
import javafx.ui.MenuSeparator;
import javafx.ui.RootPane;
import javafx.ui.TextArea;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.lang.StringBuilder;
import java.lang.System;
class BrowserModel {
attribute fileName: String;
attribute fileContents: String;
attribute recentFileNames: String*;
attribute lineWrap: Boolean;
attribute wordWrap: Boolean;
operation loadFile(file: File);
operation exit();
}
attribute BrowserModel.fileName = null;
trigger on BrowserModel.fileName[oldValue] = newValue {
if (oldValue <> null and oldValue <> newValue) {
delete recentFileNames[. == newValue];
insert oldValue as first into recentFileNames;
delete recentFileNames[indexof . > 8];
}
}
operation BrowserModel.exit() {
System.exit(0);
}
operation BrowserModel.loadFile(file: File) {
var reader = new BufferedReader(new FileReader(file));
var builder = new StringBuilder();
while (true) {
var line = reader.readLine();
if (line == null) {
break;
}
builder.append(line);
builder.append('\n');
}
reader.close();
this.fileName = file.canonicalPath;
this.fileContents = builder.toString();
}
var model = new BrowserModel;
Frame {
var: win
width: 550
height: 350
visible: true
onClose: operation() { model.exit(); }
function makeTitle(fileName: String) =
"JavaFX Browser {if fileName.length() <> 0 then " - {fileName}" else ''}";
title: bind makeTitle(model.fileName)
content: RootPane {
menubar: MenuBar {
menus: [
Menu {
var keyStrokes:KeyStroke = [_1,_2,_3,_4,_5,_6,_7,_8,_9]
text: "File"
mnemonic: F
items: bind [
MenuItem {
text: "Open"
mnemonic: O
accelerator: {
modifier: CTRL
keyStroke: O
}
action: operation() {
var fc = FileChooser {
action: operation(file: File) {
model.loadFile(file);
}
};
fc.showOpenDialog(win);
}
},
if sizeof model.recentFileNames > 0
then MenuSeparator {}
else [],
foreach (f in model.recentFileNames)
MenuItem {
mnemonic: bind keyStrokes[indexof f]
text: bind "{indexof f + 1} {f}"
action: operation() {
model.loadFile(new File(f));
}
},
MenuSeparator {},
MenuItem {
text: "Exit"
mnemonic: X
accelerator: {
modifier: CTRL
keyStroke: Q
}
action: operation() { model.exit(); }
},
]
},
Menu {
text: "View"
mnemonic: E
items: [
CheckBoxMenuItem {
text: "Word Wrap"
mnemonic: W
selected: bind model.wordWrap
},
CheckBoxMenuItem {
text: "Line Wrap"
mnemonic: L
selected: bind model.lineWrap
},
]
},
]
}
content: TextArea {
wrapStyleWord: bind model.wordWrap
lineWrap: bind model.lineWrap
text: bind model.fileContents
}
}
};