539 lines
17 KiB
Java
539 lines
17 KiB
Java
package util;
|
|
|
|
// Java imports
|
|
import java.util.*;
|
|
import javax.swing.*;
|
|
import javax.swing.border.*;
|
|
import javax.swing.text.*;
|
|
|
|
// UI display text manager
|
|
public class Localizer {
|
|
|
|
// Instance fields
|
|
private HashMap<Object, Control> controls; // Control mapping
|
|
private Locale locale; // Current message store
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////
|
|
// Constants //
|
|
///////////////////////////////////////////////////////////////////////////
|
|
|
|
// Class reference for JComboBox<String>
|
|
JComboBox<String> JCOMBOBOX = new JComboBox<String>();
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////
|
|
// Classes //
|
|
///////////////////////////////////////////////////////////////////////////
|
|
|
|
// Control settings
|
|
private static class Control {
|
|
String key; // Single-string dictionary key
|
|
String[] keys; // Multiple-string dictionary key
|
|
String tipKey; // Tooltip dictionary key
|
|
HashMap<String, String> tags; // Message overrides
|
|
Control(String tipKey) {
|
|
tags = new HashMap<String, String>();
|
|
this.tipKey = tipKey;
|
|
}
|
|
}
|
|
|
|
// Locale container
|
|
public static class Locale implements Comparable<Locale> {
|
|
|
|
// Public fields
|
|
public final String id; // Unique identifier
|
|
public final String name; // Display name
|
|
|
|
// Private fields
|
|
private HashMap<String, String> messages; // Message dictionary
|
|
|
|
// Constructor
|
|
private Locale(HashMap<String, String> messages) {
|
|
id = messages.get("locale.id");
|
|
this.messages = messages;
|
|
name = messages.get("locale.name");
|
|
}
|
|
|
|
// Comparator
|
|
public int compareTo(Locale o) {
|
|
return id.compareTo(o.id);
|
|
}
|
|
|
|
// Represent this object as a string
|
|
public String toString() {
|
|
return id + " - " + name;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////
|
|
// Static Methods //
|
|
///////////////////////////////////////////////////////////////////////////
|
|
|
|
// Parse a text file to produce a Locale object
|
|
public static Locale parse(String text) {
|
|
var chars = text.replaceAll("\\r\\n?", "\n").toCharArray();
|
|
var ret = new HashMap<String, String>(); // Output dictionary
|
|
int start = 0; // Position of first character in key or value
|
|
var stack = new Stack<String>(); // Nested key chain
|
|
int state = 0; // Current parsing context
|
|
|
|
// Process all characters
|
|
for (int x = 0, line = 1, col = 1; x < chars.length; x++, col++) {
|
|
char c = chars[x];
|
|
boolean end = x == chars.length - 1;
|
|
String pos = line + ":" + col + ": ";
|
|
boolean white = c == ' ' || c == '\t';
|
|
|
|
// Processing for newline
|
|
if (c == '\n') {
|
|
col = 0;
|
|
line++;
|
|
}
|
|
|
|
// Comment
|
|
if (state == -1) {
|
|
if (c == '\n' || end)
|
|
state = 0;
|
|
}
|
|
|
|
// Pre-key
|
|
else if (state == 0) {
|
|
|
|
// Ignore leading whitespace
|
|
if (white)
|
|
continue;
|
|
|
|
// The line is a comment
|
|
if (c == '#') {
|
|
state = -1;
|
|
continue;
|
|
}
|
|
|
|
// End of input has been reached
|
|
if (end)
|
|
break;
|
|
|
|
// The line is empty
|
|
if (c == '\n')
|
|
continue;
|
|
|
|
// Proceed to key
|
|
start = x;
|
|
state = 1;
|
|
}
|
|
|
|
// Key
|
|
else if (state == 1) {
|
|
|
|
// Any non-whitespace character is valid in a key
|
|
if (!white && c != '\n' && !end)
|
|
continue;
|
|
|
|
// Produce the key as a string
|
|
String key = new String(chars, start, x - start).trim();
|
|
|
|
// Close a key group
|
|
if (key.equals("}")) {
|
|
|
|
// Nesting error
|
|
if (stack.size() == 0) throw new RuntimeException(
|
|
pos + "Unexpected '}'");
|
|
|
|
// Syntax error
|
|
outer: for (int y = x; y < chars.length; y++) {
|
|
char h = chars[y];
|
|
if (h == '\n') break;
|
|
if (h != ' ' && h != '\t') throw new RuntimeException(
|
|
pos + "Newline expected");
|
|
}
|
|
|
|
// Proceed to key
|
|
state = 0;
|
|
stack.pop();
|
|
continue;
|
|
}
|
|
|
|
// Curly braces are not valid in keys
|
|
if (key.contains("{") || key.contains("}"))
|
|
throw new RuntimeException(
|
|
line + ": Curly braces are not allowed in keys");
|
|
|
|
// Proceed to interim
|
|
stack.push(key);
|
|
state = 2;
|
|
}
|
|
|
|
// Post-key, pre-value
|
|
if (state == 2) {
|
|
|
|
// Ignore any whitespace between key and value
|
|
if (white)
|
|
continue;
|
|
|
|
// No value is present
|
|
if (c == '\n') throw new RuntimeException(
|
|
pos + "Unexpected newline");
|
|
if (end) throw new RuntimeException(
|
|
pos + "Unexpected end of input");
|
|
|
|
// Proceed to value
|
|
start = x;
|
|
state = 3;
|
|
}
|
|
|
|
// Value
|
|
if (state == 3) {
|
|
|
|
// Escape sequence
|
|
if (c == '\\') {
|
|
|
|
// EOF
|
|
if (x == chars.length - 1)
|
|
throw new RuntimeException(
|
|
pos + "Unexpected end of input");
|
|
|
|
// Cannot escape a newline
|
|
if (chars[x + 1] == '\n')
|
|
throw new RuntimeException(
|
|
pos + "Unexpected newline");
|
|
|
|
// Skip the next character
|
|
x++;
|
|
continue;
|
|
}
|
|
|
|
// The end of the value has not yet been reached
|
|
if (c != '\n' && !end)
|
|
continue;
|
|
|
|
// Produce the value as a string
|
|
if (end)
|
|
x++;
|
|
String value = new String(chars, start, x - start).trim();
|
|
state = 0;
|
|
|
|
// Open a key group
|
|
if (value.equals("{")) {
|
|
state = 0;
|
|
continue;
|
|
}
|
|
|
|
// Check for nesting errors
|
|
int depth = 0;
|
|
for (int y = start; y < x; y++) {
|
|
switch (chars[y]) {
|
|
case '\\': y++; continue;
|
|
case '{' : depth++; continue;
|
|
case '}' : if (--depth == -1)
|
|
throw new RuntimeException(
|
|
(line - 1) + ": Unexpected '}'"
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
if (depth != 0) throw new RuntimeException(
|
|
pos + "'}' expected");
|
|
|
|
// Produce the full key as a string
|
|
var pkey = new StringBuilder();
|
|
for (String item : stack)
|
|
pkey.append((pkey.length() != 0 ? "." : "") + item);
|
|
String key = pkey.toString();
|
|
|
|
// Check for duplicate keys
|
|
String lkey = key.toLowerCase();
|
|
if (ret.get(lkey) != null) throw new RuntimeException(
|
|
(line-1)+ ": Key '" + key + "' has already been defined.");
|
|
|
|
// Add the pair to the dictionary
|
|
ret.put(lkey, value);
|
|
stack.pop();
|
|
}
|
|
|
|
}
|
|
|
|
// Perform post-processing error checks
|
|
if (state != 0 || !stack.empty()) throw new RuntimeException(
|
|
"Unexpected end of input");
|
|
if (!ret.containsKey("locale.id")) throw new RuntimeException(
|
|
"Required key not found: 'locale.id'");
|
|
if (!ret.containsKey("locale.name")) throw new RuntimeException(
|
|
"Required key not found: 'locale.name'");
|
|
ret.put("null", "");
|
|
return new Locale(ret);
|
|
}
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////
|
|
// Constructors //
|
|
///////////////////////////////////////////////////////////////////////////
|
|
|
|
// Default constructor
|
|
public Localizer() {
|
|
controls = new HashMap<Object, Control>();
|
|
}
|
|
|
|
// Parsing constructor
|
|
public Localizer(String text) {
|
|
this();
|
|
setLocale(parse(text));
|
|
}
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////
|
|
// Public Methods //
|
|
///////////////////////////////////////////////////////////////////////////
|
|
|
|
// Add a control to the collection
|
|
public boolean add(Object control, Object key) {
|
|
return add(control, key, null);
|
|
}
|
|
|
|
// Add a control with a tooltip to the collection
|
|
public boolean add(Object control, Object key, String tipKey) {
|
|
var ctrl = controls.get(control);
|
|
if (ctrl == null)
|
|
ctrl = new Control(tipKey);
|
|
|
|
// Error checking
|
|
if (control == null || key == null)
|
|
return false;
|
|
|
|
// Control takes a single string
|
|
if (key instanceof String) {
|
|
|
|
// Type validation
|
|
if (!(
|
|
control instanceof AbstractButton ||
|
|
control instanceof JFrame ||
|
|
control instanceof JInternalFrame ||
|
|
control instanceof JLabel ||
|
|
control instanceof JPanel || // TitledBorder
|
|
control instanceof JTextComponent
|
|
)) return false;
|
|
|
|
// Configure key
|
|
ctrl.key = (String) key;
|
|
}
|
|
|
|
// Control takes an array of strings
|
|
else if (key instanceof String[]) {
|
|
|
|
// Type validation
|
|
if (!(
|
|
JCOMBOBOX.getClass().isAssignableFrom(control.getClass())
|
|
)) return false;
|
|
|
|
// Configure keys
|
|
String[] keys = (String[]) key;
|
|
ctrl.keys = new String[keys.length];
|
|
System.arraycopy(keys, 0, ctrl.keys, 0, keys.length);
|
|
}
|
|
|
|
// Invalid control type
|
|
else return false;
|
|
|
|
// Add the control to the collection
|
|
controls.put(control, ctrl);
|
|
update(control);
|
|
return true;
|
|
}
|
|
|
|
// Remove all controls from being managed
|
|
public void clearControls() {
|
|
controls.clear();
|
|
}
|
|
|
|
// Evaluate the message for a given key
|
|
public String get(String key) {
|
|
return key == null ? null : evaluate(null, key);
|
|
}
|
|
|
|
// Retrieve the currently loaded locale
|
|
public Locale getLocale() {
|
|
return locale;
|
|
}
|
|
|
|
// Configure a control tag
|
|
public String put(Object control, String key, String value) {
|
|
var ctrl = controls.get(control);
|
|
|
|
// Error checking
|
|
if (ctrl == null || key == null)
|
|
return null;
|
|
|
|
// Update the control's tags
|
|
key = key.toLowerCase();
|
|
String ret = value == null ?
|
|
ctrl.tags.remove(key) :
|
|
ctrl.tags.put(key, value)
|
|
;
|
|
|
|
// Refresh the text on the control
|
|
update(control);
|
|
return ret;
|
|
}
|
|
|
|
// Remove a control from the collection
|
|
public boolean remove(Object control) {
|
|
return controls.remove(control) != null;
|
|
}
|
|
|
|
// Specify a message dictionary
|
|
public void setLocale(Locale locale) {
|
|
this.locale = locale;
|
|
}
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////
|
|
// Private Methods //
|
|
///////////////////////////////////////////////////////////////////////////
|
|
|
|
// Process substitutions and escapes on a message for a given key
|
|
private String evaluate(Control control, String key) {
|
|
|
|
// No locale is loaded
|
|
if (locale == null)
|
|
return key;
|
|
|
|
// Check that the key exists
|
|
String ret = locale.messages.get(key.toLowerCase());
|
|
if (ret == null)
|
|
return key;
|
|
|
|
// Perform all substitutions
|
|
outer: for (;;) {
|
|
var chars = ret.toCharArray();
|
|
int start = 0;
|
|
|
|
// Look for the first complete substitution
|
|
for (int x = 0; x < chars.length; x++) {
|
|
char c = chars[x];
|
|
|
|
// Escape sequence
|
|
if (c == '\\') {
|
|
x++;
|
|
continue;
|
|
}
|
|
|
|
// Begin substitution
|
|
if (c == '{')
|
|
start = x + 1;
|
|
|
|
// Substitution has not yet ended
|
|
if (c != '}')
|
|
continue;
|
|
|
|
// Determine the substitution
|
|
key = new String(chars, start, x - start);
|
|
String lkey = key.toLowerCase();
|
|
String value = control == null ? null : control.tags.get(lkey);
|
|
if (value == null)
|
|
value = locale.messages.get(lkey);
|
|
if (value == null)
|
|
value = "\\{" + key + "\\}";
|
|
|
|
// Apply the substitution to the message
|
|
ret =
|
|
new String(chars, 0, start - 1) +
|
|
value +
|
|
new String(chars, x + 1, chars.length - x - 1)
|
|
;
|
|
continue outer;
|
|
}
|
|
|
|
// No further substitutions
|
|
break;
|
|
}
|
|
|
|
// Unescape all escape sequences
|
|
var chars = ret.toCharArray();
|
|
int length = 0;
|
|
for (int x = 0; x < chars.length; x++, length++) {
|
|
char c = chars[x];
|
|
if (c == '\\') switch (c = chars[++x]) {
|
|
case 'n': c = '\n'; break;
|
|
case 't': c = '\t'; break;
|
|
}
|
|
chars[length] = c;
|
|
}
|
|
return new String(chars, 0, length);
|
|
}
|
|
|
|
// Update the text for all controls
|
|
private void update() {
|
|
for (var control : controls.keySet())
|
|
update(control);
|
|
}
|
|
|
|
// Update the text for a control
|
|
private void update(Object control) {
|
|
var ctrl = controls.get(control);
|
|
String[] keys = ctrl.key==null ? ctrl.keys : new String[]{ctrl.key};
|
|
String[] values = new String[keys.length];
|
|
|
|
// Evaluate all messages
|
|
for (int x = 0; x < keys.length; x++)
|
|
values[x] = evaluate(ctrl, keys[x]);
|
|
|
|
// Update the control's text
|
|
if (control instanceof AbstractButton)
|
|
((AbstractButton) control).setText (values[0]);
|
|
if (control instanceof JFrame)
|
|
((JFrame ) control).setTitle(values[0]);
|
|
if (control instanceof JInternalFrame)
|
|
((JInternalFrame) control).setTitle(values[0]);
|
|
if (control instanceof JLabel )
|
|
((JLabel ) control).setText (values[0]);
|
|
if (control instanceof JTextComponent)
|
|
((JTextComponent) control).setText (values[0]);
|
|
|
|
// JPanel must be wrapped in a TitledBorder
|
|
if (control instanceof JPanel) {
|
|
var border = ((JPanel) control).getBorder();
|
|
if (border instanceof TitledBorder)
|
|
((TitledBorder) border).setTitle(values[0]);
|
|
}
|
|
|
|
// Replace the contents of a JComboBox without firing events
|
|
if (JCOMBOBOX.getClass().isAssignableFrom(control.getClass())) {
|
|
|
|
// The type is explicitly verified above
|
|
@SuppressWarnings("unchecked")
|
|
var box = (JComboBox<String>) control;
|
|
|
|
// Configure working variables
|
|
var action = box.getActionListeners();
|
|
int index = box.getSelectedIndex();
|
|
var item = box.getItemListeners();
|
|
|
|
// Remove event listeners
|
|
for (var lst : action) box.removeActionListener(lst);
|
|
for (var lst : item ) box.removeItemListener (lst);
|
|
|
|
// Update contents
|
|
box.setModel(new DefaultComboBoxModel<String>(values));
|
|
box.setSelectedIndex(index);
|
|
|
|
// Restore event listeners
|
|
for (var lst : action) box.addActionListener(lst);
|
|
for (var lst : item ) box.addItemListener (lst);
|
|
}
|
|
|
|
// Update the control's tooltip text
|
|
if (control instanceof JComponent)
|
|
((JComponent) control).setToolTipText(
|
|
ctrl.tipKey == null ? null : evaluate(ctrl, ctrl.tipKey));
|
|
}
|
|
|
|
}
|