From 5ee08a4d93a239feedabb945db1fa876d86ee696 Mon Sep 17 00:00:00 2001 From: Guy Perfect Date: Fri, 31 Jul 2020 14:20:27 -0500 Subject: [PATCH] Introducing utility library, implementing Localizer --- locale/.gitignore | 0 locale/en_US.txt | 6 + makefile | 12 +- src/desktop/Main.java | 11 +- src/desktop/util/FrameTimer.java | 585 +++++++++++++++++++++++ src/desktop/util/LEDataInputStream.java | 249 ++++++++++ src/desktop/util/LEDataOutputStream.java | 218 +++++++++ src/desktop/util/Localizer.java | 433 +++++++++++++++++ src/desktop/util/Util.java | 284 +++++++++++ src/desktop/util/XML.java | 137 ++++++ 10 files changed, 1928 insertions(+), 7 deletions(-) delete mode 100644 locale/.gitignore create mode 100644 locale/en_US.txt create mode 100644 src/desktop/util/FrameTimer.java create mode 100644 src/desktop/util/LEDataInputStream.java create mode 100644 src/desktop/util/LEDataOutputStream.java create mode 100644 src/desktop/util/Localizer.java create mode 100644 src/desktop/util/Util.java create mode 100644 src/desktop/util/XML.java diff --git a/locale/.gitignore b/locale/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/locale/en_US.txt b/locale/en_US.txt new file mode 100644 index 0000000..33db39c --- /dev/null +++ b/locale/en_US.txt @@ -0,0 +1,6 @@ +app { + title { + empty PVB Emulator + loaded {app.filename} - {app.title.empty} + } +} diff --git a/makefile b/makefile index bd67714..f6467e9 100644 --- a/makefile +++ b/makefile @@ -19,7 +19,7 @@ default: @echo @echo "Usage: make " @echo " Package recipes:" - @echo " build Produces a .jar and deletes all intermediate files" + @echo " bundle Produces a .jar and deletes all intermediate files" @echo " clean Deletes all output files" @echo " core Check the native core library for style errors" @echo " desktop Compiles the Java desktop application" @@ -39,8 +39,8 @@ default: ############################################################################### # Perform a full build process -.PHONY: build -build: +.PHONY: bundle +bundle: @make -s core @make -s desktop @make -s native @@ -67,7 +67,7 @@ core: .PHONY: desktop desktop: clean_desktop @echo " Compiling Java desktop application" - @javac -sourcepath src/desktop -d . src/desktop/Main.java + @javac -sourcepath src/desktop -d . -Xlint:unchecked src/desktop/Main.java # Build all native modules .PHONY: native @@ -83,12 +83,12 @@ pack: $(eval jarname = "pvbemu_`date +%Y%m%d`.jar") @echo " Bundling into $(jarname)" @jar -cfe $(jarname) Main *.class \ - locale native src vue makefile license.txt + locale native src util vue makefile license.txt # Delete only Java .class files .PHONY: clean_desktop clean_desktop: - @rm -r -f *.class vue + @rm -r -f *.class util vue # Delete everything but the .jar .PHONY: clean_most diff --git a/src/desktop/Main.java b/src/desktop/Main.java index 8595c7a..a84902f 100644 --- a/src/desktop/Main.java +++ b/src/desktop/Main.java @@ -1,9 +1,18 @@ +import java.util.*; +import javax.swing.*; +import util.*; + // Desktop application primary class public class Main { // Program entry point public static void main(String[] args) { - System.out.println("Hello, world!"); + var loc = new Localizer(); + loc.set(Util.textRead("locale/en_US.txt")); + loc.put("app.filename", "wario.vb"); + var window = new JFrame(); + loc.add(window, "app.title.loaded"); + window.setVisible(true); } } diff --git a/src/desktop/util/FrameTimer.java b/src/desktop/util/FrameTimer.java new file mode 100644 index 0000000..a7eeef1 --- /dev/null +++ b/src/desktop/util/FrameTimer.java @@ -0,0 +1,585 @@ +package util; + +// Java imports +import java.util.concurrent.*; +import java.util.concurrent.locks.*; +import javax.sound.sampled.*; + +// Background timer using either a realtime clock or audio for timing +public class FrameTimer { + + // Instance fields + private Callback callback; // Event handler + private double rate; // Frame rate in seconds + private int source; // Timing source + private int state; // Operation state + + // Audio fields + private int block; // Current byte buffer index + private byte[][] blocks; // Reusable byte buffers + private double buffer; // Buffer length in seconds + private int channels; // Number channels + private double delay; // Delay in seconds + private int frames; // Audio frames per timer frame + private SourceDataLine line; // Output line + private int sampleRate; // Rate in samples per second + private boolean written; // Samples have been written this frame + private LinkedBlockingQueue queue; // Sample queue + + // Threading fields + private Thread audio; // Background audio processing + private CyclicBarrier cycAudio; // Synchronization with audio thread + private CyclicBarrier cycTimer; // Synchronization with timer thread + private ReentrantLock lock; // Allows callback to control the timer + private Thread timer; // Background timer processing + + // Type for timer event handler + public interface Callback { + void onFrame(FrameTimer source); + } + + + + /////////////////////////////////////////////////////////////////////////// + // Constants // + /////////////////////////////////////////////////////////////////////////// + + // Sources + public static final int CLOCK = 0; + public static final int AUDIO = 1; + + // States + public static final int STOPPED = 0; + public static final int RUNNING = 1; + public static final int PAUSED = 2; + + + + /////////////////////////////////////////////////////////////////////////// + // Constructors // + /////////////////////////////////////////////////////////////////////////// + + // Default constructor + public FrameTimer() { + buffer = 0.05; + cycAudio = new CyclicBarrier(2); + cycTimer = new CyclicBarrier(2); + channels = 2; + delay = 0.1; + lock = new ReentrantLock(); + queue = new LinkedBlockingQueue(); + rate = 1; + sampleRate = 44100; + state = STOPPED; + } + + + + /////////////////////////////////////////////////////////////////////////// + // Public Methods // + /////////////////////////////////////////////////////////////////////////// + + // Retrieve the audio buffer size + public synchronized double getBuffer() { + return buffer; + } + + // Retrieve the number of audio channels + public synchronized int getChannels() { + return channels; + } + + // Retrieve the audio delay + public synchronized double getDelay() { + return delay; + } + + // Calculate the number of audio frames per timer frame + public synchronized int getFrames() { + return audioFrames(rate); + } + + // Retrieve the frame rate + public synchronized double getRate() { + return rate; + } + + // Retrieve the audio sampling rate + public synchronized int getSampleRate() { + return sampleRate; + } + + // Retrieve the timing source + public synchronized int getSource() { + return source; + } + + // Retrieve the operation state + public synchronized int getState() { + return state; + } + + // Pause the timer + public synchronized boolean pause() { + boolean isTimer = Thread.currentThread() == timer; + + // The timer thread already owns the lock + if (isTimer && lock.isHeldByCurrentThread()) { + if (state == RUNNING) + state = PAUSED; + return state == PAUSED; + } + + // Obtain the lock + lock.lock(); + + // Invalid state + if (state != RUNNING) { + lock.unlock(); + return state == PAUSED; + } + + // Configure state + state = PAUSED; + + // Pause timer thread + if (!isTimer) try { + timer.interrupt(); + lock.unlock(); + cycTimer.await(); + } catch (Exception e) { } + + return true; + } + + // Resume running the paused timer + public synchronized boolean resume() { + boolean isTimer = Thread.currentThread() == timer; + + // The timer thread already owns the lock + if (isTimer && lock.isHeldByCurrentThread()) { + if (state == PAUSED) + state = RUNNING; + return state == RUNNING; + } + + // Obtain the lock + lock.lock(); + + // Invalid state + if (state != PAUSED) { + lock.unlock(); + return state == RUNNING; + } + + // Configure state + state = RUNNING; + + // Resume timer thread + if (!isTimer) try { + lock.unlock(); + cycTimer.await(); + } catch (Exception e) { } + + return true; + } + + // Specify the audio buffer length + public synchronized double setBuffer(double buffer) { + return this.buffer = state == STOPPED && buffer > 0 ? + buffer : this.buffer; + } + + // Specify the callback handler + public synchronized boolean setCallback(Callback callback) { + if (state != STOPPED) + return false; + this.callback = callback; + return true; + } + + // Specify the number of audio channels + public synchronized int setChannels(int channels) { + return this.channels = state == STOPPED && (channels - 1 & ~1) != 0 ? + channels : this.channels; + } + + // Specify the audio delay + public synchronized double setDelay(double delay) { + return this.delay = state == STOPPED && delay > 0 ? delay : this.delay; + } + + // Specify the frame rate + public synchronized double setRate(double rate) { + return this.rate = state == STOPPED && rate > 0 ? rate : this.rate; + } + + // Specify the audio sampling rate + public synchronized int setSampleRate(int sampleRate) { + return this.sampleRate = state == STOPPED && sampleRate > 0 ? + sampleRate : this.sampleRate; + } + + // Begin timer operations + public synchronized boolean start(int source) { + + // Error checking + if (source != CLOCK && source != AUDIO || state != STOPPED || + callback == null || Thread.currentThread() == timer || + source == AUDIO && buffer > delay) { + return false; + } + + // Audio processing + if (source == AUDIO) try { + + // Open the output line + AudioFormat fmt = + new AudioFormat(sampleRate, 16, channels, true, false); + line = AudioSystem.getSourceDataLine(fmt); + line.open(fmt); + + // Configure audio fields + frames = audioFrames(rate); + block = 0; + blocks = new byte[(int) Math.ceil(delay / rate)] + [frames * channels * 2]; + } + + // Could not open the audio line + catch (Exception e) { + line = null; + return false; + } + + // Configure state + this.source = source; + state = RUNNING; + + // Spawn and start timer thread + cycTimer.reset(); + timer = new Thread(()->timer()); + timer.setDaemon(true); + timer.start(); + + // Spawn and start audio thread + if (source == AUDIO) { + cycAudio.reset(); + audio = new Thread(()->audio()); + audio.setDaemon(true); + audio.start(); + } + + // Synchronize with timer thread + try { cycTimer.await(); } catch (Exception e) { } + + return true; + } + + // End timer operations + public synchronized boolean stop() { + boolean isTimer = Thread.currentThread() == timer; + + // The timer thread already owns the lock + if (isTimer && lock.isHeldByCurrentThread()) + return true; + + // Obtain the lock + lock.lock(); + + // Invalid state + if (state == STOPPED) { + lock.unlock(); + return true; + } + + // Configure state + boolean paused = state == PAUSED; + state = STOPPED; + + // Stop timer thread + if (!isTimer) try { + if (!paused) timer.interrupt(); + lock.unlock(); + if ( paused) cycTimer.await(); + cycTimer.await(); + } catch (Exception e) { } + + return true; + } + + // Write audio samples as bytes to output + public boolean write(byte[] samples, int offset) { + int size = frames * channels * 2; + + // Error checking + if (Thread.currentThread() != timer || state == STOPPED || written || + samples == null || offset < 0 || samples.length < offset + size) + return false; + + // Add a new sample block to the queue + block = (block + 1) % blocks.length; + byte[] block = blocks[this.block]; + System.arraycopy(samples, offset, block, 0, size); + queue.offer(block); + return written = true; + } + + // Write audio samples as shorts to output + public boolean write(short[] samples, int offset) { + int size = frames * channels; + + // Error checking + if (Thread.currentThread() != timer || state == STOPPED || written || + samples == null || offset < 0 || samples.length < offset + size) + return false; + + // Encode the samples as bytes + block = (block + 1) % blocks.length; + byte[] block = blocks[this.block]; + for (int src = 0, dest = 0; src < size; src++) { + short sample = samples[offset + src]; + block[dest++] = (byte) sample; + block[dest++] = (byte) (sample >> 8); + } + queue.offer(block); + return written = true; + } + + // Write audio samples as floats to output (range -1 to +1) + public boolean write(float[] samples, int offset) { + int size = frames * channels; + + // Error checking + if (Thread.currentThread() != timer || state == STOPPED || written || + samples == null || offset < 0 || samples.length < offset + size) + return false; + + // Encode the samples as bytes + block = (block + 1) % blocks.length; + byte[] block = blocks[this.block]; + for (int src = 0, dest = 0; src < size; src++) { + short sample = (short) Math.round(32767 * + Math.min(1, Math.max(-1, samples[offset + src])) ); + block[dest++] = (byte) sample; + block[dest++] = (byte) (sample >> 8); + } + queue.offer(block); + return written = true; + } + + + + /////////////////////////////////////////////////////////////////////////// + // Private Methds // + /////////////////////////////////////////////////////////////////////////// + + // Calculate the number of audio sampling frames in some number of seconds + private int audioFrames(double seconds) { + return Math.max(1, (int) Math.round(seconds * sampleRate)); + } + + // Handler for pause operations -- invoked by timer thread + private long onPause(long reference, boolean isCallback) { + + // Track the current time + if (source == CLOCK) + reference = System.nanoTime() - reference; + + // Pause audio thread + else try { + audio.interrupt(); + line.stop(); + cycAudio.await(); + } catch (Exception e) { } + + // Synchronization + try { + if (!isCallback) + cycTimer.await(); // Synchronize with invoking thread + else lock.unlock(); + cycTimer.await(); // Wait for resume() or stop() + } catch (Exception e) { } + + // Calculate a new reference time + if (source == CLOCK) + reference = System.nanoTime() - reference; + + // Unpause audio thread + else try { cycAudio.await(); } catch (Exception e) { } + + return reference; + } + + // Handler for stop operations -- invoked by timer thread + private int onStop(boolean paused, boolean isCallback) { + + // Stop the audio thread + if (source == AUDIO) try { + audio.interrupt(); + line.stop(); + cycAudio.await(); + } catch (Exception e) { } + + // Synchronization + try { + if (!isCallback || paused) + cycTimer.await(); // Synchronize with invoking thread + else lock.unlock(); + } catch (Exception e) { } + + // Cleanup + blocks = null; + timer = null; + return 0; + } + + + + /////////////////////////////////////////////////////////////////////////// + // Thread Methods // + /////////////////////////////////////////////////////////////////////////// + + // Audio thread entry point + private void audio() { + + // Synchronize with timer thread + try { cycAudio.await(); } catch (Exception e) { } + + // Initialize working variables + byte[] block = new byte[audioFrames(delay ) * channels * 2]; + byte[] buffer = new byte[audioFrames(this.buffer) * channels * 2]; + int blockPos = 0; + int bufferPos = -buffer.length; // Less than 0 means not full + + // Audio processing + line.start(); + for (;;) { + + // Fill the buffer with samples, blocking until full + while (bufferPos < 0) { + + // Load bytes from the current block + if (blockPos < block.length) { + int size = Math.min(block.length - blockPos, -bufferPos); + System.arraycopy(block, blockPos, buffer, + bufferPos + buffer.length, size); + blockPos += size; + bufferPos += size; + } + + // Fetch a new sample block, blocking until one is available + else try { + block = queue.take(); + blockPos = 0; + } + + // The timer state has changed + catch (Exception e) { + audio.interrupt(); // take() clears interrupt status + break; + } + + } + + // Send samples to the output, blocking until sent + if (!audio.isInterrupted()) { + bufferPos += + line.write(buffer, bufferPos, buffer.length - bufferPos); + if (bufferPos == buffer.length) + bufferPos = -buffer.length; + } + + // Check for changes to the timer state + if (state == RUNNING) + continue; + Thread.interrupted(); + + // Timer has paused + if (state == PAUSED) try { + cycAudio.await(); // Synchronize with timer thread + cycAudio.await(); // Wait for resume() or stop() + } catch (Exception e) { } + + // Timer has stopped + if (state == STOPPED) { + try { line.close(); } catch (Exception e) { } + try { cycAudio.await(); } catch (Exception e) { } + audio = null; + return; + } + + // Resume playback + line.start(); + } + + } + + // Timer thread entry point + private int timer() { + + // Synchronize with other threads + try { + if (source == AUDIO) + cycAudio.await(); // Synchronize with audio thread + cycTimer.await(); // Synchronize with parent thread + } catch (Exception e) { } + + // Initialize working variables + byte[] empty = new byte[frames * channels * 2]; + long reference = source == AUDIO ? 0 : System.nanoTime(); + long target = source == AUDIO ? audioFrames(rate) : + Math.max(1, (long) Math.round(rate * 1000000000L)); + + // Timer processing + for (boolean first = true;; first = false) { + long current = source == CLOCK ? System.nanoTime() : + line.getLongFramePosition(); + long remain = target - current + reference; + + // Processing on all frames but the first + if (!first) { + + // Wait until the next frame interval + if (remain > 0) try { + if (source == AUDIO) + remain = remain * 1000000000L / sampleRate; + remain = Math.max(1000000, remain); + Thread.sleep(remain / 1000000, (int) (remain % 1000000)); + continue; + } catch (Exception e) { timer.interrupt(); } + + // Another thread configured the timer state + if (Thread.interrupted()) { + if (state == PAUSED) + reference = onPause(reference, false); + if (state == STOPPED) + return onStop(false, false); + continue; + } + + } + + // Invoke the event callback + written = false; + callback.onFrame(this); + if (source == AUDIO && !written) + queue.offer(empty); + + // The callback configured the timer state + if (lock.isHeldByCurrentThread()) { + boolean paused = state == PAUSED; + if (state == PAUSED) + reference = onPause(reference, true); + if (state == STOPPED) + return onStop(paused, true); + continue; + } + + // Track processed frame time + reference = current + remain; + } + + } + +} diff --git a/src/desktop/util/LEDataInputStream.java b/src/desktop/util/LEDataInputStream.java new file mode 100644 index 0000000..20a5acd --- /dev/null +++ b/src/desktop/util/LEDataInputStream.java @@ -0,0 +1,249 @@ +package util; + +// Java imports +import java.io.*; + +// Little-endian implementation of DataInputStream +public class LEDataInputStream extends FilterInputStream { + + // Instance fields + private DataInputStream data; // Data-decoding stream + + + + /////////////////////////////////////////////////////////////////////////// + // Constructors // + /////////////////////////////////////////////////////////////////////////// + + // Default constructor + public LEDataInputStream(InputStream stream) { + super(stream = stream instanceof LEDataInputStream ? + ((LEDataInputStream) stream).in : stream); + data = stream instanceof DataInputStream ? + (DataInputStream) stream : new DataInputStream(stream); + } + + // Byte array constructor + public LEDataInputStream(byte[] data) { + this(new ByteArrayInputStream(data)); + } + + + + /////////////////////////////////////////////////////////////////////////// + // Public Methods // + /////////////////////////////////////////////////////////////////////////// + + // Reads a boolean from the underlying input stream + public boolean readBoolean() throws IOException { + return data.readByte() != 0; + } + + // Reads an array of booleans from the underlying input stream + public boolean[] readBooleans(int count) throws IOException { + return readBooleans(new boolean[count], 0, count); + } + + // Reads an array of booleans from the underlying input stream + public boolean[] readBooleans(boolean[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readBoolean(); + return v; + } + + // Reads a byte from the underlying input stream + public byte readByte() throws IOException { + return data.readByte(); + } + + // Reads an array of bytes from the underlying input stream + public byte[] readBytes(int count) throws IOException { + return readBytes(new byte[count], 0, count); + } + + // Reads an array of bytes from the underlying input stream + public byte[] readBytes(byte[] v, int offset, int length) + throws IOException { + data.read(v, offset, length); + return v; + } + + // Reads a character from the underlying input stream + public char readChar() throws IOException { + return (char) Short.reverseBytes(data.readShort()); + } + + // Reads an array of characters from the underlying input stream + public char[] readChars(int count) throws IOException { + return readChars(new char[count], 0, count); + } + + // Reads an array of characters from the underlying input stream + public char[] readChars(char[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readChar(); + return v; + } + + // Reads a double from the underlying input stream + public double readDouble() throws IOException { + return readDouble(false); + } + + // Reads a double from the underlying input stream + public double readDouble(boolean finite) throws IOException { + long bits = readLong(); + double ret = Double.longBitsToDouble(bits); + if (finite && !Double.isFinite(ret)) throw new RuntimeException( + String.format("Non-finite double value 0x%016X", bits)); + return ret; + } + + // Reads an array of doubles from the underlying input stream + public double[] readDoubles(int count) throws IOException { + return readDoubles(new double[count], 0, count, false); + } + + // Reads an array of doubles from the underlying input stream + public double[] readDoubles(int count, boolean finite) throws IOException { + return readDoubles(new double[count], 0, count, finite); + } + + // Reads an array of doubles from the underlying input stream + public double[] readDoubles(double[] v, int offset, int length) + throws IOException { + return readDoubles(v, offset, length, false); + } + + // Reads an array of doubles from the underlying input stream + public double[] readDoubles(double[] v, int offset, int length, + boolean finite) throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readDouble(finite); + return v; + } + + // Reads a float from the underlying input stream + public float readFloat() throws IOException { + return readFloat(false); + } + + // Reads a float from the underlying input stream + public float readFloat(boolean finite) throws IOException { + int bits = readInt(); + float ret = Float.intBitsToFloat(bits); + if (finite && !Float.isFinite(ret)) throw new RuntimeException( + String.format("Non-finite float value 0x%08X", bits)); + return ret; + } + + // Reads an array of floats from the underlying input stream + public float[] readFloats(int count) throws IOException { + return readFloats(new float[count], 0, count, false); + } + + // Reads an array of floats from the underlying input stream + public float[] readFloats(int count, boolean finite) throws IOException { + return readFloats(new float[count], 0, count, finite); + } + + // Reads an array of floats from the underlying input stream + public float[] readFloats(float[] v, int offset, int length) + throws IOException { + return readFloats(v, offset, length, false); + } + + // Reads an array of floats from the underlying input stream + public float[] readFloats(float[] v, int offset, int length, + boolean finite) throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readFloat(finite); + return v; + } + + // Reads an int from the underlying input stream + public int readInt() throws IOException { + return Integer.reverseBytes(data.readInt()); + } + + // Reads an array of ints from the underlying input stream + public int[] readInts(int count) throws IOException { + return readInts(new int[count], 0, count); + } + + // Reads an array of ints from the underlying input stream + public int[] readInts(int[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readInt(); + return v; + } + + // Reads a long from the underlying input stream + public long readLong() throws IOException { + return Long.reverseBytes(data.readLong()); + } + + // Reads an array of longs from the underlying input stream + public long[] readLongs(int count) throws IOException { + return readLongs(new long[count], 0, count); + } + + // Reads an array of longs from the underlying input stream + public long[] readLongs(long[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readLong(); + return v; + } + + // Reads a 24-bit integer from the underlying input stream + public int readMiddle() throws IOException { + int v = readShort() & 0xFFFF; + return readByte() << 16 | v; + } + + // Reads an array of 24-bit integers from the underlying input stream + public int[] readMiddles(int count) throws IOException { + return readMiddles(new int[count], 0, count); + } + + // Reads an array of 24-bit integers from the underlying input stream + public int[] readMiddles(int[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readMiddle(); + return v; + } + + // Reads a short from the underlying input stream + public short readShort() throws IOException { + return Short.reverseBytes(data.readShort()); + } + + // Reads an array of shorts from the underlying input stream + public short[] readShorts(int count) throws IOException { + return readShorts(new short[count], 0, count); + } + + // Reads an array of shorts from the underlying input stream + public short[] readShorts(short[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + v[offset + x] = readShort(); + return v; + } + + // Reads from the stream in a modified UTF-8 string + public String readUTF() throws IOException { + return data.readUTF(); + } + + // Advances forward in the stream some number of bytes + public int skipBytes(int n) throws IOException { + return data.skipBytes(n); + } + +} diff --git a/src/desktop/util/LEDataOutputStream.java b/src/desktop/util/LEDataOutputStream.java new file mode 100644 index 0000000..c3daa63 --- /dev/null +++ b/src/desktop/util/LEDataOutputStream.java @@ -0,0 +1,218 @@ +package util; + +// Java imports +import java.io.*; +import java.nio.charset.*; + +// Little-endian implementation of DataOutputStream +public class LEDataOutputStream extends FilterOutputStream { + + // Instance fields + private DataOutputStream data; // Data-encoding stream + + + + /////////////////////////////////////////////////////////////////////////// + // Constructors // + /////////////////////////////////////////////////////////////////////////// + + // Default constructor + public LEDataOutputStream() { + this(new ByteArrayOutputStream()); + } + + // Stream constructor + public LEDataOutputStream(OutputStream stream) { + super(stream = stream instanceof LEDataOutputStream ? + ((LEDataOutputStream) stream).out : stream); + data = stream instanceof DataOutputStream ? + (DataOutputStream) stream : new DataOutputStream(stream); + } + + + + /////////////////////////////////////////////////////////////////////////// + // Public Methods // + /////////////////////////////////////////////////////////////////////////// + + // Returns the current value of the underlying buffer + public int size() { + return out instanceof ByteArrayOutputStream ? + ((ByteArrayOutputStream) out).size() : -1; + } + + // Produce a byte array containing this stream's data + public byte[] toByteArray() throws IOException { + return out instanceof ByteArrayOutputStream ? + ((ByteArrayOutputStream) out).toByteArray() : null; + } + + // Writes a boolean to the underlying output stream as a 1-byte value + public void writeBoolean(boolean v) throws IOException { + data.writeBoolean(v); + } + + // Writes an array of booleans to the underlying output stream + public void writeBooleans(boolean[] v) throws IOException { + writeBooleans(v, 0, v.length); + } + + // Writes an array of booleans to the underlying output stream + public void writeBooleans(boolean[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeBoolean(v[offset + x]); + } + + // Writes out a byte to the underlying output stream as a 1-byte value + public void writeByte(int v) throws IOException { + data.writeByte(v); + } + + // Writes out the string to the underlying output stream + public void writeBytes(String s) throws IOException { + data.writeBytes(s); + } + + // Writes an array of bytes to the underlying output stream + public void writeBytes(byte[] v) throws IOException { + writeBytes(v, 0, v.length); + } + + // Writes an array of bytes to the underlying output stream + public void writeBytes(byte[] v, int offset, int length) + throws IOException { + data.write(v, offset, length); + } + + // Writes a character to the underlying output stream as a 2-byte value + public void writeChar(int v) throws IOException { + data.writeChar(Short.reverseBytes((short) v)); + } + + // Writes a string to the underlying output stream + public void writeChars(String s) throws IOException { + writeChars(s.toCharArray()); + } + + // Writes an array of characters to the underlying output stream + public void writeChars(char[] v) throws IOException { + writeChars(v, 0, v.length); + } + + // Writes an array of characters to the underlying output stream + public void writeChars(char[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeChar(v[offset + x]); + } + + // Converts the double argument to a long using doubleToLongBits + public void writeDouble(double v) throws IOException { + data.writeLong(Long.reverseBytes(Double.doubleToLongBits(v))); + } + + // Writes an array of doubles to the underlying output stream + public void writeDoubles(double[] v) throws IOException { + writeDoubles(v, 0, v.length); + } + + // Writes an array of doubles to the underlying output stream + public void writeDoubles(double[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeDouble(v[offset + x]); + } + + // Converts the float argument to an int using floatToIntBits + public void writeFloat(float v) throws IOException { + data.writeInt(Integer.reverseBytes(Float.floatToIntBits(v))); + } + + // Writes an array of floats to the underlying output stream + public void writeFloats(float[] v) throws IOException { + writeFloats(v, 0, v.length); + } + + // Writes an array of floats to the underlying output stream + public void writeFloats(float[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeFloat(v[offset + x]); + } + + // Writes an int to the underlying output stream as four bytes + public void writeInt(int v) throws IOException { + data.writeInt(Integer.reverseBytes(v)); + } + + // Writes an array of ints to the underlying output stream + public void writeInts(int[] v) throws IOException { + writeInts(v, 0, v.length); + } + + // Writes an array of ints to the underlying output stream + public void writeInts(int[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeInt(v[offset + x]); + } + + // Writes an int to the underlying output stream as three bytes + public void writeMiddle(int v) throws IOException { + writeShort((short) v ); + writeByte ((byte ) (v >> 16)); + } + + // Writes an array of 24-bit integers to the underlying output stream + public void writeMiddles(int[] v) throws IOException { + writeMiddles(v, 0, v.length); + } + + // Writes an array of 24-bit integers to the underlying output stream + public void writeMiddles(int[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeMiddle(v[offset + x]); + } + + // Writes a long to the underlying output stream as eight bytes + public void writeLong(long v) throws IOException { + data.writeLong(Long.reverseBytes(v)); + } + + // Writes an array of longs to the underlying output stream + public void writeLong(long[] v) throws IOException { + writeLongs(v, 0, v.length); + } + + // Writes an array of longs to the underlying output stream + public void writeLongs(long[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeLong(v[offset + x]); + } + + // Writes a short to the underlying output stream as two bytes + public void writeShort(int v) throws IOException { + data.writeShort(Short.reverseBytes((short) v)); + } + + // Writes an array of shorts to the underlying output stream + public void writeShorts(short[] v) throws IOException { + writeShorts(v, 0, v.length); + } + + // Writes an array of shorts to the underlying output stream + public void writeShorts(short[] v, int offset, int length) + throws IOException { + for (int x = 0; x < length; x++) + writeShort(v[offset + x]); + } + + // Writes a string to the underlying output stream using modified UTF-8 + public void writeUTF(String str) throws IOException { + data.writeUTF(str); + } + +} diff --git a/src/desktop/util/Localizer.java b/src/desktop/util/Localizer.java new file mode 100644 index 0000000..4903944 --- /dev/null +++ b/src/desktop/util/Localizer.java @@ -0,0 +1,433 @@ +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 controls; // Control mapping + private HashMap messages; // Message dictionary + + + + /////////////////////////////////////////////////////////////////////////// + // Constants // + /////////////////////////////////////////////////////////////////////////// + + // Class reference for JComboBox + JComboBox JCOMBOBOX = new JComboBox(); + + + + /////////////////////////////////////////////////////////////////////////// + // Constructors // + /////////////////////////////////////////////////////////////////////////// + + // Default constructor + public Localizer() { + controls = new HashMap(); + messages = new HashMap(); + } + + + + /////////////////////////////////////////////////////////////////////////// + // Public Methods // + /////////////////////////////////////////////////////////////////////////// + + // Add a control to the collection + public boolean add(Object control, Object key) { + + // Error checking + if (control == null || key == null) + return false; + + // Control takes a single string + if (key instanceof String) { + if (!( + control instanceof AbstractButton || + control instanceof JFrame || + control instanceof JInternalFrame || + control instanceof JPanel || // TitledBorder + control instanceof JTextComponent + )) return false; + } + + // Control takes an array of strings + else if (key instanceof String[]) { + if (!( + JCOMBOBOX.getClass().isAssignableFrom(control.getClass()) + )) return false; + } + + // Invalid control type + else return false; + + // Add the control to the collection + controls.put(control, key); + update(); + return true; + } + + // Remove all controls from being managed + public void clearControls() { + controls.clear(); + } + + // Configure a dictionary entry + public String put(String key, String value) { + String ret = value == null ? + messages.remove(key) : + messages.put(key, value) + ; + update(); + return ret; + } + + // Remove a control from the collection + public boolean remove(Object control) { + return controls.remove(control) != null; + } + + // Specify a message dictionary + public void set(String text) { + + // Pre-processing + messages.clear(); + if (text == null) { + update(); + return; + } + + // Configure working variables + var chars = text.replaceAll("\\r\\n?", "\n").toCharArray(); + int col = 1; // Current character on the current line + int line = 1; // Current line in the file + int start = 0; // Position of first character in key or value + var stack = new Stack(); // Nested key chain + int state = 0; // Current parsing context + + // Process all characters + for (int x = 0; x < chars.length; x++, col++) { + char c = chars[x]; + boolean end = x == chars.length - 1; + String pos = line + ":" + col + ": "; + boolean white = c == ' ' || c == '\t'; + + // Comment + if (state == -1) { + if (c != '\n' && !end) + continue; + col = 1; + state = 0; + line++; + } + + // 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') { + col = 1; + line++; + 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(); + col = 1; + state = 0; + line++; + + // 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 + ":" + (x - start + y + 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 (messages.get(lkey) != null) throw new RuntimeException( + (line-1)+ ": Key '" + key + "' has already been defined."); + + // Add the pair to the dictionary + messages.put(lkey, value); + stack.pop(); + } + + } + + // Update all controls + update(); + } + + + + /////////////////////////////////////////////////////////////////////////// + // Private Methods // + /////////////////////////////////////////////////////////////////////////// + + // Process substitutions and escapes on a message for a given key + private String evaluate(String key) { + String ret = messages.get(key.toLowerCase()); + + // The topmost key does not exist in the dictionary + if (ret == null) + return null; + + // 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 value = messages.get(key.toLowerCase()); + 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() { + + // Process all controls + for (var control : controls.keySet()) { + Object key = controls.get(control); + String[] values = null; + + // One string + if (key instanceof String) + values = new String[] { evaluate((String) key) }; + + // Multiple strings + else { + String[] keys = (String[]) key; + values = new String[keys.length]; + for (int x = 0; x < keys.length; x++) + values[x] = evaluate(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 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) 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(values)); + box.setSelectedIndex(index); + + // Restore event listeners + for (var lst : action) box.addActionListener(lst); + for (var lst : item ) box.addItemListener (lst); + } + + } + + } + +} diff --git a/src/desktop/util/Util.java b/src/desktop/util/Util.java new file mode 100644 index 0000000..0471f19 --- /dev/null +++ b/src/desktop/util/Util.java @@ -0,0 +1,284 @@ +package util; + +// Java imports +import java.awt.*; +import java.awt.event.*; +import java.awt.image.*; +import java.io.*; +import java.nio.charset.*; +import java.util.*; +import javax.imageio.*; +import javax.swing.*; +import javax.swing.border.*; +import javax.swing.event.*; +import javax.swing.plaf.basic.*; + +// General utility methods +public final class Util { + + // This class cannot be instantiated + private Util() { } + + + + /////////////////////////////////////////////////////////////////////////// + // Types // + /////////////////////////////////////////////////////////////////////////// + + // Event listener interfaces + public interface OnClose { void call(WindowEvent e); } + public interface OnClose2 { void call(InternalFrameEvent e); } + public interface OnFocus { void call(FocusEvent e); } + public interface OnKey { void call(KeyEvent e); } + public interface OnMouse { void call(MouseEvent e); } + public interface OnResize { void call(ComponentEvent e); } + + // Data class for byte-order marks + private static class BOM { + byte[] mark; + Charset set; + BOM(byte[] m, Charset s) { mark = m; set = s; } + } + + + + /////////////////////////////////////////////////////////////////////////// + // Constants // + /////////////////////////////////////////////////////////////////////////// + + // Text file byte-order marks + private static final BOM[] BOMS = { + new BOM(new byte[] { -17, -69, -65 }, StandardCharsets.UTF_8 ), + new BOM(new byte[] { - 2, - 1 }, StandardCharsets.UTF_16BE), + new BOM(new byte[] { - 1, - 2 }, StandardCharsets.UTF_16LE), + }; + + + + /////////////////////////////////////////////////////////////////////////// + // Static Methods // + /////////////////////////////////////////////////////////////////////////// + + // Apply an alpha value to a Color object + public static Color alpha(Color color, float alpha) { + int a = (int) Math.round(alpha * 255); + return color == null || a < 0 || a > 255 ? null : + new Color(color.getRGB() & 0x00FFFFFF | a << 24, true); + } + + // Blend one color into another + public static Color blend(Color front, Color back, float alpha) { + + // Error checking + if (front == null || back == null || + !Float.isFinite(alpha) || alpha < 0 || alpha > 1) + return null; + + // Decompose components + var colors = new Color[] { front, back }; + var argb = new float[3][4]; + for (int x = 0; x < 2; x++) { + int bits = colors[x].getRGB(); + for (int y = 3; y >= 0; y++, bits >>= 8) + argb[x][y] = (bits & 0xFF) / 255.0f; + } + + // Combine the colors + argb[2][0] = argb[0][0] + argb[1][0] * (1 - argb[0][0]); + for (int x = 1; x < 4; x++) + argb[2][x] = ( + argb[0][x] * argb[0][0] + + argb[1][x] * argb[1][0] * (1 - argb[0][0]) + ) / argb[2][0]; + + // Produce the resulting Color object + int bits = 0; + for (int x = 0; x < 4; x++) + bits = bits << 8 | (int) Math.round(argb[2][x] * 255); + return new Color(bits, true); + } + + // Read a file, first from disk, then from .jar + public static byte[] fileRead(String filename) { + InputStream stream = null; + + // Open the file on disk + try { stream = new FileInputStream(filename); } + + // File on disk could not be opened, so get resource from .jar + catch (Exception e) { + stream = Util.class.getResourceAsStream("/" + filename); + if (stream == null) + return null; // Resource in .jar could not be found + } + + // Read the file data into memory + byte[] data = null; + try { data = stream.readAllBytes(); } catch (Exception e) { } + try { stream.close(); } catch (Exception e) { } + return data; + } + + // Read an image file as an icon + public static Icon iconRead(String filename) { + BufferedImage img = imageRead(filename); + return img == null ? null : new ImageIcon(img); + } + + // Read an image file by filename + public static BufferedImage imageRead(String filename) { + try { return + ImageIO.read(new ByteArrayInputStream(fileRead(filename))); + } catch (Exception e) { return null; } + } + + // Read an image file by handle + public static BufferedImage imageRead(File file) { + try { return imageRead(file.getAbsolutePath()); } + catch (Exception e) { return null; } + } + + // Produce a WindowListener with a functional interface + public static WindowListener onClose(OnClose close) { + return new WindowListener() { + public void windowActivated (WindowEvent e) { } + public void windowClosed (WindowEvent e) { } + public void windowDeactivated(WindowEvent e) { } + public void windowDeiconified(WindowEvent e) { } + public void windowIconified (WindowEvent e) { } + public void windowOpened (WindowEvent e) { } + public void windowClosing (WindowEvent e) + { if (close != null) close.call(e); } + }; + } + + // Produce an InternalFrameListener with a functional interface + public static InternalFrameListener onClose2(OnClose2 close) { + return new InternalFrameListener() { + public void internalFrameActivated (InternalFrameEvent e) { } + public void internalFrameClosed (InternalFrameEvent e) { } + public void internalFrameDeactivated(InternalFrameEvent e) { } + public void internalFrameDeiconified(InternalFrameEvent e) { } + public void internalFrameIconified (InternalFrameEvent e) { } + public void internalFrameOpened (InternalFrameEvent e) { } + public void internalFrameClosing (InternalFrameEvent e) + { if (close != null) close.call(e); } + }; + } + + // Produce a FocusListener with functional interfaces + public static FocusListener onFocus(OnFocus focus, OnFocus blur) { + return new FocusListener() { + public void focusGained(FocusEvent e) + { if (focus != null) focus.call(e); } + public void focusLost (FocusEvent e) + { if (blur != null) blur .call(e); } + }; + } + + // Produce a KeyListener with functional interfaces + public static KeyListener onKey(OnKey down, OnKey up) { + return new KeyListener() { + public void keyTyped (KeyEvent e) { } + public void keyPressed (KeyEvent e) + { if (down != null) down.call(e); } + public void keyReleased(KeyEvent e) + { if (up != null) up .call(e); } + }; + } + + // Produce a MouseMotionListener with a functional interface + public static MouseMotionListener onMouseMove(OnMouse move, OnMouse drag) { + return new MouseMotionListener() { + public void mouseMoved (MouseEvent e) + { if (move != null) move.call(e); } + public void mouseDragged(MouseEvent e) + { if (drag != null) drag.call(e); } + }; + } + + // Produce a MouseListener with functional interfaces + public static MouseListener onMouse(OnMouse down, OnMouse up) { + return new MouseListener() { + public void mouseClicked (MouseEvent e) { } + public void mouseEntered (MouseEvent e) { } + public void mouseExited (MouseEvent e) { } + public void mousePressed (MouseEvent e) + { if (down != null) down.call(e); } + public void mouseReleased(MouseEvent e) + { if (up != null) up .call(e); } + }; + } + + // Produce a ComponentListener with a functional interface + public static ComponentListener onResize(OnResize resize) { + return new ComponentListener() { + public void componentHidden (ComponentEvent e) { } + public void componentMoved (ComponentEvent e) { } + public void componentShown (ComponentEvent e) { } + public void componentResized(ComponentEvent e) + { if (resize != null) resize.call(e); } + }; + } + + // Configure the Swing look-and-feel with the system theme + public static boolean setSystemLAF() { + try { + UIManager.setLookAndFeel( + UIManager.getSystemLookAndFeelClassName()); + return true; + } catch (Exception e) { return false; } + } + + // Produce a JSplitPane with an un-styled divider + public static JSplitPane splitPane(int orientation) { + var split = new JSplitPane(orientation, null, null); + split.setContinuousLayout(true); + split.setDividerSize(2); + split.setUI(new BasicSplitPaneUI() { + public BasicSplitPaneDivider createDefaultDivider() { + return new BasicSplitPaneDivider(this) { + public void setBorder(Border b) { } + }; + } + }); + split.setBorder(null); + return split; + } + + // Read a text file, accounting for BOMs and guaranteeing Unix line endings + public static String textRead(String filename) { + + // Read the file + byte[] data = fileRead(filename); + if (data == null) + return null; + + // Configure working variables + byte[] mark = null; + Charset set = StandardCharsets.ISO_8859_1; // Default charset + + // Check for a byte-order mark + outer: for (BOM bom : BOMS) { + if (data.length < bom.mark.length) + continue; + for (int x = 0; x < bom.mark.length; x++) + if (data[x] != bom.mark[x]) + continue outer; + mark = bom.mark; + set = bom.set; + break; + } + + // Remove the byte-order mark from the data + if (mark != null) { + byte[] temp = new byte[data.length - mark.length]; + System.arraycopy(data, mark.length, temp, 0, temp.length); + data = temp; + } + + // Produce a string with LF-style (Unix) line endings + return new String(data, set).replaceAll("\\r\\n?", "\n"); + } + +} diff --git a/src/desktop/util/XML.java b/src/desktop/util/XML.java new file mode 100644 index 0000000..5d75b81 --- /dev/null +++ b/src/desktop/util/XML.java @@ -0,0 +1,137 @@ +package util; + +// Java imports +import java.io.*; +import java.util.*; +import javax.xml.parsers.*; +import org.w3c.dom.*; + +// Utility methods for managing XML documents +public class XML { + + // This class cannot be instantiated + private XML() { } + + + + /////////////////////////////////////////////////////////////////////////// + // Constants // + /////////////////////////////////////////////////////////////////////////// + + // Parsing instance + private static final DocumentBuilder XML_PARSER; + + // Static initializer + static { + DocumentBuilder parser = null; + try { parser = + DocumentBuilderFactory.newInstance().newDocumentBuilder(); + } catch (Exception e) { } + XML_PARSER = parser; + } + + + + /////////////////////////////////////////////////////////////////////////// + // Static Methods // + /////////////////////////////////////////////////////////////////////////// + + // Retrieve an attribute value from an XML element + public static String attribute(Element element, String attribute) { + return element.hasAttribute(attribute) ? + element.getAttribute(attribute) : null; + } + + // Retrieve the child elements of an XML node + public static Element[] children(Node node) { + var nodes = node.getChildNodes(); + int count = nodes.getLength(); + var ret = new ArrayList(); + for (int x = 0; x < count; x++) { + node = nodes.item(x); + if (node.getNodeType() == Node.ELEMENT_NODE) + ret.add((Element) node); + } + return ret.toArray(new Element[ret.size()]); + } + + // Retrieve the first node matching the given hierarchy + public static Element element(Node node, String hierarchy) { + + // Error checking + if (node == null || hierarchy == null) + return null; + + // Propagate down the hierarchy + outer: for (String name : hierarchy.split("\\.")) { + var nodes = node.getChildNodes(); + int count = nodes.getLength(); + for (int x = 0; x < count; x++) { + node = nodes.item(x); + if ( + node.getNodeType() == Node.ELEMENT_NODE && + node.getNodeName().equals(name) + ) continue outer; + } + return null; + } + + return (Element) node; + } + + // Retrieve all nodes matching the endpoint of a given hierarchy + public static Element[] elements(Node node, String hierarchy) { + + // Find the first matching node + node = element(node, hierarchy); + if (node == null) + return new Element[0]; + + // Working variables + var names = hierarchy.split("\\."); + String name = names[names.length - 1]; + var ret = new ArrayList(); + + // Include all siblings with the same tag name + ret.add((Element) node); + for (;;) { + node = node.getNextSibling(); + if (node == null) + break; + if ( + node.getNodeType() == Node.ELEMENT_NODE && + node.getNodeName().equals(name) + ) ret.add((Element) node); + } + + return ret.toArray(new Element[ret.size()]); + } + + // Read and parse an XML file + public static Node read(String filename) { + try { return XML_PARSER.parse( + new ByteArrayInputStream(Util.fileRead(filename))); + } catch (Exception e) { return null; } + } + + // Retrieve the text content of an element + public static String text(Node node) { + + // Error checking + if (node == null) + return null; + + // Find the child node called #text + var nodes = node.getChildNodes(); + int count = nodes.getLength(); + for (int x = 0; x < count; x++) { + node = nodes.item(x); + if (node.getNodeType() != Node.TEXT_NODE) + continue; + String text = node.getNodeValue(); + return text == null ? "" : text.trim(); + } + return ""; + } + +}