commit e85143f9eb06c978c7c1296114c3fc140d1837af Author: gered Date: Sun Nov 29 21:18:49 2015 -0500 initial commit. Xunkar's release of 1.4.3 sources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdf2506 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.idea/ +.settings/ +target/ +out/ +.project +.classpath +*.iml +*.ipr +*.iws diff --git a/lib/JNativeHook-1.3.jar b/lib/JNativeHook-1.3.jar new file mode 100644 index 0000000..388b533 Binary files /dev/null and b/lib/JNativeHook-1.3.jar differ diff --git a/lib/Sidekick-1.2.jar b/lib/Sidekick-1.2.jar new file mode 100644 index 0000000..bea995f Binary files /dev/null and b/lib/Sidekick-1.2.jar differ diff --git a/lib/XStream-1.4.4.jar b/lib/XStream-1.4.4.jar new file mode 100644 index 0000000..dcedd5a Binary files /dev/null and b/lib/XStream-1.4.4.jar differ diff --git a/org/fenix/llanfair/Actions.java b/org/fenix/llanfair/Actions.java new file mode 100644 index 0000000..4a2e355 --- /dev/null +++ b/org/fenix/llanfair/Actions.java @@ -0,0 +1,444 @@ +package org.fenix.llanfair; + +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.io.xml.DomDriver; +import java.awt.Dimension; +import java.awt.event.ActionEvent; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ResourceBundle; +import javax.swing.Action; +import javax.swing.JFileChooser; +import javax.swing.JOptionPane; +import org.fenix.llanfair.config.Settings; +import org.fenix.llanfair.dialog.EditRun; +import org.fenix.llanfair.dialog.EditSettings; +import org.fenix.llanfair.extern.WSplit; +import org.fenix.utils.about.AboutDialog; +import org.jnativehook.keyboard.NativeKeyEvent; + +/** + * Regroups all actions, in the meaning of {@link Action}, used by Llanfair. + * All inputs and menu items callbacks are processed by this delegate to + * simplify the work of the main class. + * + * @author Xavier "Xunkar" Sencert + * @version 1.0 + */ +final class Actions { + + private static final long GHOST_DELAY = 300L; + private static ResourceBundle BUNDLE = null; + + private Llanfair master; + + private File file; + private JFileChooser fileChooser; + + private volatile long lastUnsplit; + private volatile long lastSkip; + + /** + * Creates a new delegate. This constructor is package private since it + * only need be called by the main class. + * + * @param owner the Llanfair instance owning this delegate + */ + Actions( Llanfair owner ) { + assert ( owner != null ); + master = owner; + + file = null; + fileChooser = new JFileChooser( "." ); + + lastUnsplit = 0L; + lastSkip = 0L; + + if ( BUNDLE == null ) { + BUNDLE = Llanfair.getResources().getBundle( "llanfair" ); + } + } + + /** + * Processes the given native key event. This method must be called from + * a thread to prevent possible deadlock. + * + * @param event the native key event to process + */ + void process( NativeKeyEvent event ) { + assert ( event != null ); + + int keyCode = event.getKeyCode(); + Run run = master.getRun(); + Run.State state = run.getState(); + + if ( keyCode == Settings.KEY_SPLT.get() ) { + split(); + } else if ( keyCode == Settings.KEY_RSET.get() ) { + reset(); + } else if ( keyCode == Settings.KEY_USPL.get() ) { + unsplit(); + } else if ( keyCode == Settings.KEY_SKIP.get() ) { + skip(); + } else if ( keyCode == Settings.KEY_STOP.get() ) { + if ( state == Run.State.ONGOING ) { + run.stop(); + } + } else if ( keyCode == Settings.KEY_PAUS.get() ) { + if ( state == Run.State.ONGOING ) { + run.pause(); + } else if ( state == Run.State.PAUSED ) { + run.resume(); + } + } else if ( keyCode == Settings.KEY_LOCK.get() ) { + master.setIgnoreNativeInputs( !master.ignoresNativeInputs() ); + } + } + + /** + * Processes the given action event. It is assumed here that the action + * event is one triggered by a menu item. This method must be called from + * a thread to prevent possible deadlock. + * + * @param event the event to process + */ + void process( ActionEvent event ) { + assert ( event != null ); + + Run run = master.getRun(); + MenuItem source = ( MenuItem ) event.getSource(); + + if ( source == MenuItem.EDIT ) { + EditRun dialog = new EditRun( run ); + dialog.display( true, master ); + } else if ( source == MenuItem.NEW ) { + if ( confirmOverwrite() ) { + master.setRun( new Run() ); + } + } else if ( source == MenuItem.OPEN ) { + open( null ); + } else if ( source == MenuItem.OPEN_RECENT ) { + open( new File( event.getActionCommand() ) ); + } else if ( source == MenuItem.IMPORT ) { + imprt(); + } else if ( source == MenuItem.SAVE ) { + run.saveLiveTimes( !run.isPersonalBest() ); + run.reset(); + save(); + } else if ( source == MenuItem.SAVE_AS ) { + file = null; + save(); + } else if ( source == MenuItem.RESET ) { + reset(); + } else if ( source == MenuItem.LOCK ) { + master.setIgnoreNativeInputs( true ); + } else if ( source == MenuItem.UNLOCK ) { + master.setIgnoreNativeInputs( false ); + } else if ( source == MenuItem.SETTINGS ) { + EditSettings dialog = new EditSettings(); + dialog.display( true, master ); + } else if ( source == MenuItem.ABOUT ) { + about(); + } else if ( source == MenuItem.EXIT ) { + if ( confirmOverwrite() ) { + master.dispose(); + } + } + } + + /** + * Performs a split or starts the run if it is ready. Can also resume a + * paused run in case the run is segmented. + */ + private void split() { + Run run = master.getRun(); + Run.State state = run.getState(); + if ( state == Run.State.ONGOING ) { + long milli = System.nanoTime() / 1000000L; + long start = run.getSegment( run.getCurrent() ).getStartTime(); + if ( milli - start > GHOST_DELAY ) { + run.split(); + } + } else if ( state == Run.State.READY ) { + run.start(); + } else if ( state == Run.State.PAUSED && run.isSegmented() ) { + run.resume(); + } + } + + /** + * Resets the current run to a ready state. If the user asked to be warned + * a pop-up will ask confirmation in case some live times are better. + */ + private void reset() { + Run run = master.getRun(); + if ( run.getState() != Run.State.NULL ) { + if ( !Settings.GNR_WARN.get() || confirmOverwrite() ) { + run.reset(); + } + } + } + + /** + * Performs an "unsplit" on the current run. If a split has been made, it + * is canceled and the time that passed after said split is added back to + * the timer, as if the split had not taken place. + */ + private void unsplit() { + Run run = master.getRun(); + Run.State state = run.getState(); + if ( state == Run.State.ONGOING || state == Run.State.STOPPED ) { + long milli = System.nanoTime() / 1000000L; + if ( milli - lastUnsplit > GHOST_DELAY ) { + lastUnsplit = milli; + run.unsplit(); + } + } + } + + /** + * Skips the current split in the run. Skipping a split sets an undefined + * time for the current segment and merges the live time of the current + * segment with the following one. + */ + private void skip() { + Run run = master.getRun(); + if ( run.getState() == Run.State.ONGOING ) { + long milli = System.nanoTime() / 1000000L; + if ( milli - lastSkip > GHOST_DELAY ) { + lastSkip = milli; + run.skip(); + } + } + } + + /** + * Displays a dialog to let the user select a file. The user is able to + * cancel this action, which results in a {@code null} being returned. + * + * @return a file selected by the user or {@code null} if he canceled + */ + private File selectFile() { + int option = fileChooser.showDialog( master, + Language.action_accept.get() ); + + if ( option == JFileChooser.APPROVE_OPTION ) { + return fileChooser.getSelectedFile(); + } else { + return null; + } + } + + /** + * Asks the user to confirm the discard of the current run. The popup + * window will only trigger if the current run has not been saved after + * some editing or if the run presents better times. + * + * @return {@code true} if the user wants to discard the run + */ + private boolean confirmOverwrite() { + boolean before = master.ignoresNativeInputs(); + master.setIgnoreNativeInputs( true ); + + Run run = master.getRun(); + boolean betterRun = run.isPersonalBest(); + boolean betterSgt = run.hasSegmentsBest(); + + if ( betterRun || betterSgt ) { + String message = betterRun + ? Language.WARN_BETTER_RUN.get() + : Language.WARN_BETTER_TIMES.get(); + + int option = JOptionPane.showConfirmDialog( master, message, + Language.WARNING.get(), JOptionPane.YES_NO_CANCEL_OPTION, + JOptionPane.WARNING_MESSAGE ); + + if ( option == JOptionPane.CANCEL_OPTION ) { + master.setIgnoreNativeInputs( false ); + return false; + } else if ( option == JOptionPane.YES_OPTION ) { + run.saveLiveTimes( !betterRun ); + run.reset(); + save(); + } + } + master.setIgnoreNativeInputs( before ); + return true; + } + + /** + * Opens the given file. If the file is {@code null}, the user is asked + * to select one. Before anything is done, the user is also asked for + * a confirmation if the current run has not been saved. + * + * @param file the file to open + */ + void open( File file ) { + if ( !confirmOverwrite() ) { + return; + } + if ( file == null ) { + if ( ( file = selectFile() ) == null ) { + return; + } + } + this.file = file; + String name = file.getName(); + try { + open(); + } catch ( Exception ex ) { + master.showError( Language.error_read_file.get( name ) ); + this.file = null; + } + } + + /** + * Opens the currently selected file. This method will first try to open + * the file using the new method (XStream XML) and if it fails will try to + * use the legacy method (Java ObjectStream.) + * + * @throws Exception if the reading operation fails + */ + private void open() throws Exception { + BufferedInputStream in = null; + try { + in = new BufferedInputStream( new FileInputStream( file ) ); + try { + in.mark( Integer.MAX_VALUE ); + xmlRead( in ); + } catch ( Exception ex ) { + in.reset(); + legacyRead( new ObjectInputStream( in ) ); + } + MenuItem.recentlyOpened( "" + file ); + } catch ( Exception ex ) { + throw ex; + } finally { + try { + in.close(); + } catch ( Exception ex ) { + //$FALL-THROUGH$ + } + } + } + + /** + * Reads a stream on a run file using the new (since 1.5) XML method. This + * method will not be able to read a legacy run file and will throw an + * exception if confronted with such run file. + * + * @param in the input stream on the run file + */ + private void xmlRead( InputStream in ) { + XStream xml = new XStream( new DomDriver() ); + master.setRun( ( Run ) xml.fromXML( in ) ); + } + + /** + * Reads a stream on a run file using the legacy Java method. This method + * might fail if the given file is not a Llanfair run. + * + * @param in an input stream on the run file + * @throws Exception if the stream cannot be read + */ + private void legacyRead( ObjectInputStream in ) throws Exception { + master.setRun( ( Run ) in.readObject() ); + try { + Settings.GNR_SIZE.set( ( Dimension ) in.readObject(), true ); + } catch ( Exception ex ) { + // $FALL-THROUGH$ + } + } + + /** + * Saves the currently opened run to the currently selected file. If no + * file has been selected, the user is asked for one. + */ + private void save() { + if ( file == null ) { + if ( ( file = selectFile() ) == null ) { + return; + } + } + Settings.GNR_COOR.set( master.getLocationOnScreen(), true ); + Settings.GNR_SIZE.set( master.getSize(), true ); + + String name = file.getName(); + BufferedOutputStream out = null; + try { + XStream xml = new XStream( new DomDriver() ); + out = new BufferedOutputStream( new FileOutputStream( file ) ); + xml.toXML( master.getRun(), out ); + } catch ( Exception ex ) { + master.showError( Language.error_write_file.get( name ) ); + } finally { + try { + out.close(); + } catch ( Exception ex ) { + // $FALL-THROUGH$ + } + } + } + + /** + * Imports a run from another timer application. If no file has been + * selected, the user is asked for one. As of now, only WSplit run files + * are supported. + */ + private void imprt() { + if ( !confirmOverwrite() ) { + return; + } + if ( file == null ) { + if ( ( file = selectFile() ) == null ) { + return; + } + } + String name = file.getName(); + BufferedReader in = null; + try { + in = new BufferedReader( new FileReader( file ) ); + WSplit.parse( master, in ); + } catch ( Exception ex ) { + master.showError( Language.error_import_run.get( name ) ); + } finally { + try { + in.close(); + } catch ( Exception ex ) { + // $FALL-THROUGH$ + } + } + } + + /** + * Displays the "about" dialog. The dialog displays the version of Llanfair, + * the creative commons licence, the credits of development, a link to + * Llanfair's website and a link to donate. + */ + private void about() { + AboutDialog dialog = new AboutDialog( + master, Language.title_about.get() ); + dialog.setMessage( BUNDLE.getString( "about" )); + try { + dialog.setWebsite( new URL( BUNDLE.getString( "website" ) ) ); + } catch ( MalformedURLException ex ) { + // $FALL-THROUGH$ + } + try { + dialog.setDonateLink( new URL( BUNDLE.getString( "donate" ) ), + Llanfair.getResources().getIcon( "donate" ) ); + } catch ( MalformedURLException ex ) { + // $FALL-THROUGH$ + } + dialog.display(); + } +} diff --git a/org/fenix/llanfair/Counters.java b/org/fenix/llanfair/Counters.java new file mode 100644 index 0000000..64e4c47 --- /dev/null +++ b/org/fenix/llanfair/Counters.java @@ -0,0 +1,171 @@ +package org.fenix.llanfair; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.Icon; +import javax.swing.event.TableModelListener; +import javax.swing.table.TableModel; +import org.fenix.llanfair.Language; + +import org.fenix.utils.TableModelSupport; + +/** + * + * @author Xavier "Xunkar" Sencert + */ +public class Counters implements TableModel, Serializable { + + // -------------------------------------------------------------- CONSTANTES + + public static final long serialVersionUID = 1000L; + + public static final int COLUMN_ICON = 0; + + public static final int COLUMN_NAME = 1; + + public static final int COLUMN_START = 2; + + public static final int COLUMN_INCREMENT = 3; + + public static final int COLUMN_COUNT = 4; + + // -------------------------------------------------------------- ATTRIBUTS + + private List data; + + private TableModelSupport tmSupport; + + // ---------------------------------------------------------- CONSTRUCTEURS + + public Counters() { + data = new ArrayList(); + tmSupport = new TableModelSupport(this); + } + + public int getColumnCount() { + return COLUMN_COUNT; + } + + public int getRowCount() { + return data.size(); + } + + public Object getValueAt(int rowIndex, int columnIndex) { + if (rowIndex < 0 || rowIndex >= data.size()) { + throw new IllegalArgumentException("illegal counter id " + rowIndex); + } + return data.get(rowIndex).get(columnIndex); + } + + public String getColumnName(int columnIndex) { + switch (columnIndex) { + case COLUMN_ICON: return "" + Language.ICON; + case COLUMN_INCREMENT: return "" + Language.INCREMENT; + case COLUMN_NAME: return "" + Language.NAME; + case COLUMN_START: return "" + Language.START_VALUE; + } + return null; + } + + public Class getColumnClass(int columnIndex) { + switch (columnIndex) { + case COLUMN_ICON: return Icon.class; + case COLUMN_INCREMENT: return Integer.class; + case COLUMN_NAME: return String.class; + case COLUMN_START: return Integer.class; + } + return null; + } + + public boolean isCellEditable(int rowIndex, int columnIndex) { + return true; + } + + public void setValueAt(Object aValue, int rowIndex, int columnIndex) { + if (rowIndex < 0 || rowIndex >= data.size()) { + throw new IllegalArgumentException("illegal counter id " + rowIndex); + } + data.get(rowIndex).set(columnIndex, aValue); + } + + public void addTableModelListener(TableModelListener l) { + tmSupport.addTableModelListener(l); + } + + public void removeTableModelListener(TableModelListener l) { + tmSupport.removeTableModelListener(l); + } + + // ----------------------------------------------------------- CLASSES + + public static class Counter implements Serializable { + + public static final long serialVersionUID = 1000L; + + private String name; + + private Icon icon; + + private int increment; + + private int start; + + private int saved; + + private int live; + + public Counter() { + name = "" + Language.UNTITLED; + icon = null; + start = 0; + live = 0; + increment = 1; + } + + public Object get(int columnIndex) { + switch (columnIndex) { + case COLUMN_ICON: return icon; + case COLUMN_INCREMENT: return increment; + case COLUMN_NAME: return name; + case COLUMN_START: return start; + default: return name; + } + } + + public void set(int columnIndex, Object value) { + switch (columnIndex) { + case COLUMN_ICON: icon = (Icon) value; break; + case COLUMN_INCREMENT: increment = (Integer) value; break; + case COLUMN_NAME: name = (String) value; break; + case COLUMN_START: start = (Integer) value; break; + } + } + + public Icon getIcon() { + return icon; + } + + public String getName() { + return name; + } + + public int getLive() { + return live; + } + + public int getStart() { + return start; + } + + public int getSaved() { + return saved; + } + + public void nextStep() { + live += increment; + } + } + +} diff --git a/org/fenix/llanfair/Language.java b/org/fenix/llanfair/Language.java new file mode 100644 index 0000000..d68f549 --- /dev/null +++ b/org/fenix/llanfair/Language.java @@ -0,0 +1,292 @@ +package org.fenix.llanfair; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import org.fenix.utils.Resources; + +/** + * Enumeration of all externalized strings used by {@code Llanfair}. While it is + * possible to ask directly the {@link Resources} singleton that this class uses + * we prefer declaring explicitely every token here to make sure we never call + * a non-existent reference. + * + * @author Xavier "Xunkar" Sencert + * @see Resources + */ +public enum Language { + + // Settings > Generic + setting_alwaysOnTop, + setting_language, + setting_viewerLanguage, + setting_recentFiles, + setting_coordinates, + setting_dimension, + setting_compareMethod, + setting_accuracy, + setting_locked, + setting_warnOnReset, + + // Settings > Color + setting_color_background, + setting_color_foreground, + setting_color_time, + setting_color_timer, + setting_color_timeGained, + setting_color_timeLost, + setting_color_newRecord, + setting_color_title, + setting_color_highlight, + setting_color_separators, + + // Settings > Hotkey + setting_hotkey_split, + setting_hotkey_unsplit, + setting_hotkey_skip, + setting_hotkey_reset, + setting_hotkey_stop, + setting_hotkey_pause, + setting_hotkey_lock, + + // Settings > Header + setting_header_goal, + setting_header_title, + + // Settings > History + setting_history_rowCount, + setting_history_tabular, + setting_history_blankRows, + setting_history_multiline, + setting_history_merge, + setting_history_liveTimes, + setting_history_deltas, + setting_history_icons, + setting_history_iconSize, + setting_history_offset, + setting_history_alwaysShowLast, + setting_history_segmentFont, + setting_history_timeFont, + + // Settings > Core + setting_core_accuracy, + setting_core_icons, + setting_core_iconSize, + setting_core_segmentName, + setting_core_splitTime, + setting_core_segmentTime, + setting_core_bestTime, + setting_core_segmentTimer, + setting_core_timerFont, + setting_core_segmentTimerFont, + + // Settings > Graph + setting_graph_display, + setting_graph_scale, + + // Settings > Footer + setting_footer_display, + setting_footer_useSplitData, + setting_footer_verbose, + setting_footer_bestTime, + setting_footer_multiline, + setting_footer_deltaLabels, + + // Accuracy + accuracy_seconds, + accuracy_tenth, + accuracy_hundredth, + + // Compare + compare_best_overall_run, + compare_sum_of_best_segments, + + // Merge + merge_none, + merge_live, + merge_delta, + + // MenuItem + menuItem_edit, + menuItem_new, + menuItem_open, + menuItem_open_recent, + menuItem_import, + menuItem_save, + menuItem_save_as, + menuItem_reset, + menuItem_lock, + menuItem_unlock, + menuItem_resize_default, + menuItem_resize_preferred, + menuItem_settings, + menuItem_about, + menuItem_exit, + + // Errors + error_read_file, + error_write_file, + error_import_run, + + // Actions + action_accept, + + // Titles + title_about, + + GENERAL, + TIMER, + FOOTER, + MISC, + USE_MAIN_FONT, + LB_GOAL, + ICON, + COLORS, + + // Edit Dialog + ED_SEGMENTED, + TT_ED_SEGMENTED, + + // Panels Title + PN_DIMENSION, + PN_DISPLAY, + PN_FONTS, + PN_SCROLLING, + + // History + HISTORY, + MERGE_DELTA, + MERGE_LIVE, + MERGE_NONE, + TT_HS_OFFSET, + + // Core + LB_CR_BEST, + LB_CR_SEGMENT, + LB_CR_SPLIT, + + // Footer + LB_FT_BEST, + LB_FT_DELTA, + LB_FT_DELTA_BEST, + LB_FT_LIVE, + LB_FT_SEGMENT, + LB_FT_SPLIT, + + /* + * Messages. + */ + ICON_TOO_BIG, + ILLEGAL_TIME, + ILLEGAL_SEGMENT_TIME, + INPUT_NAN, + INPUT_NEGATIVE, + INVALID_TIME_STAMP, + WARN_BETTER_RUN, + WARN_BETTER_TIMES, + WARN_RESET_SETTINGS, + + /* + * Tooltips. + */ + TT_ADD_SEGMENT, + TT_COLOR_PICK, + TT_COLUMN_BEST, + TT_COLUMN_SEGMENT, + TT_COLUMN_TIME, + TT_REMOVE_SEGMENT, + TT_MOVE_SEGMENT_UP, + TT_MOVE_SEGMENT_DOWN, + + /* + * Run.State enumeration. + */ + RUN_NULL, + RUN_OVER, + RUN_READY, + RUN_STOPPED, + + /* + * Time.Accuracy enumeration. + */ + ACCURACY, + + /* + * Miscellaneous tokens. + */ + ACCEPT, + APPLICATION, + BEST, + CANCEL, + COMPARE_METHOD, + COMPONENTS, + DISABLED, + EDITING, + ERROR, + GOAL, + IMAGE, + INPUTS, + MAX_ORDINATE, + NAME, + RUN_TITLE, + SEGMENT, + SEGMENTS, + SPLIT, + TIME, + UNTITLED, + WARNING, + + /* + * 1.4 + */ + INCREMENT, + START_VALUE; + + public static final Locale[] LANGUAGES = new Locale[] { + Locale.ENGLISH, + Locale.FRENCH, + Locale.GERMAN, + new Locale("nl"), + new Locale("sv") + }; + + public static final Map LOCALE_NAMES = + new HashMap(); + static { + LOCALE_NAMES.put("de", "Deutsch"); + LOCALE_NAMES.put("en", "English"); + LOCALE_NAMES.put("fr", "Français"); + LOCALE_NAMES.put("nl", "Nederlands"); + LOCALE_NAMES.put("sv", "Svenska"); + } + + // -------------------------------------------------------------- INTERFACE + + /** + * Returns the localized string get of this language element. + * + * @return the localized string for this element. + */ + public String get() { + return Llanfair.getResources().getString(name()); + } + + /** + * Returns the localized string get of this language element. This method + * also passes down an array of parameters to replace the tokens with. + * + * @param parameters - the array of values for each token of the string. + * @return the localized string filled with the given parameters. + */ + public String get(Object... parameters) { + return Llanfair.getResources().getString(name(), parameters); + } + + /** + * The string representation of an enumerate is the localized string + * corresponding to its name. + */ + @Override public String toString() { + return get(); + } +} diff --git a/org/fenix/llanfair/Llanfair.java b/org/fenix/llanfair/Llanfair.java new file mode 100644 index 0000000..7ad5e57 --- /dev/null +++ b/org/fenix/llanfair/Llanfair.java @@ -0,0 +1,467 @@ +package org.fenix.llanfair; + +import java.awt.Dimension; +import java.awt.Font; +import java.awt.GraphicsEnvironment; +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import java.awt.event.WindowEvent; +import java.awt.event.WindowListener; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import javax.swing.JOptionPane; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; +import javax.swing.ToolTipManager; +import javax.swing.UIManager; +import javax.swing.event.TableModelEvent; +import javax.swing.event.TableModelListener; +import org.fenix.llanfair.config.Settings; +import org.fenix.llanfair.gui.RunPane; +import org.fenix.utils.gui.BorderlessFrame; +import org.fenix.utils.Resources; +import org.fenix.utils.locale.LocaleDelegate; +import org.fenix.utils.locale.LocaleEvent; +import org.fenix.utils.locale.LocaleListener; +import org.jnativehook.GlobalScreen; +import org.jnativehook.NativeHookException; +import org.jnativehook.keyboard.NativeKeyEvent; +import org.jnativehook.keyboard.NativeKeyListener; + +/** + * Main frame executing Llanfair. + * + * @author Xavier "Xunkar" Sencert + * @version 1.5 + */ +public class Llanfair extends BorderlessFrame implements TableModelListener, + LocaleListener, MouseWheelListener, ActionListener, NativeKeyListener, + PropertyChangeListener, WindowListener { + + private static Resources RESOURCES = null; + + + static { + ToolTipManager.sharedInstance().setInitialDelay( 1000 ); + ToolTipManager.sharedInstance().setDismissDelay( 7000 ); + ToolTipManager.sharedInstance().setReshowDelay( 0 ); + } + + private Run run; + private RunPane runPane; + + private Actions actions; + + private JPopupMenu popupMenu; + + private volatile boolean ignoreNativeInputs; + + private Dimension preferredSize; + + /** + * Creates and initializes the application. As with any Swing application + * this constructor should be called from within a thread to avoid + * dead-lock. + */ + private Llanfair() { + super( "Llanfair" ); + LocaleDelegate.setDefault( Settings.GNR_LANG.get() ); + LocaleDelegate.addLocaleListener( this ); + + RESOURCES = new Resources( "res" ); + registerFonts(); + setLookAndFeel(); + + run = new Run(); + runPane = null; + ignoreNativeInputs = false; + preferredSize = null; + actions = new Actions( this ); + + setMenu(); + setBehavior(); + setRun( run ); + + setVisible( true ); + } + + /** + * Main entry point of the application. This is the method called by Java + * when a user executes the JAR. Simply instantiantes a new Llanfair object. + * If an argument is passed, the program will not launch but will instead + * enter localization mode, dumping all language variables for the specified + * locale. + * + * @param args array of command line parameters supplied at launch + */ + public static void main( String[] args ) { + if ( args.length > 0 ) { + String locale = args[0]; + LocaleDelegate.setDefault( new Locale( locale ) ); + RESOURCES = new Resources( "res" ); + dumpLocalization(); + System.exit( 0 ); + } + SwingUtilities.invokeLater( new Runnable() { + @Override public void run() { + new Llanfair(); + } + } ); + } + + /** + * Grabs the resources of Llanfair. The Resources object is a front-end + * for every classpath resources associated with the application, including + * localization strings, icons, and properties. + * + * @return the resources object + */ + public static Resources getResources() { + return RESOURCES; + } + + /** + * Returns the run currently associated with this instance of Llanfair. + * While the run can be empty, it cannot be {@code null}. + * + * @return the current run + */ + Run getRun() { + return run; + } + + /** + * Sets the run to represent in this application to the given run. If the + * GUI does not exist (in other words, we are registering the first run) it + * is created on the fly. + * + * @param run the run to represent, cannot be {@code null} + */ + public final void setRun( Run run ) { + if ( run == null ) { + throw new NullPointerException( "Null run" ); + } + this.run = run; + // If we have a GUI, set the new model; else, create the GUI + if ( runPane != null ) { + runPane.setRun( run ); + } else { + runPane = new RunPane( run ); + add( runPane ); + } + Settings.setRun( run ); + run.addTableModelListener( this ); + run.addPropertyChangeListener( this ); + MenuItem.setActiveState( run.getState() ); + + setPreferredSize( preferredSize ); + pack(); + + // Replace the window to the run preferred location; center if none + Point location = Settings.GNR_COOR.get(); + if ( location == null ) { + setLocationRelativeTo( null ); + } else { + setLocation( location ); + } + } + + /** + * Indicates whether or not Llanfair currently ignores all native inputs. + * Since native inputs can be caught even when the application does not have + * the focus, it is necessary to be able to lock the application when the + * user needs to do something else whilst not interfering with the behavior + * of Llanfair. + * + * @return {@code true} if the current instance ignores native inputs + */ + public synchronized boolean ignoresNativeInputs() { + return ignoreNativeInputs; + } + + /** + * Tells Llanfair whether to ignore native input events or not. Since native + * inputs can be caught even when the application does not have the focus, + * it is necessary to be able to lock the application when the user needs + * to do something else whilst not interfering with the behavior of + * Llanfair. + * + * @param ignore if Llanfair must ignore the native inputs or not + */ + public synchronized void setIgnoreNativeInputs( boolean ignore ) { + ignoreNativeInputs = ignore; + } + + /** + * Outputs the given error in a dialog box. Only errors that are made for + * and useful to the user need to be displayed that way. + * + * @param message the localized error message + */ + void showError( String message ) { + JOptionPane.showMessageDialog( + this, message, Language.ERROR.get(), JOptionPane.ERROR_MESSAGE + ); + } + + /** + * Sets the look and feel of the application. Provides a task bar icon and + * a general system dependent theme. + */ + private void setLookAndFeel() { + setIconImage( RESOURCES.getImage( "Llanfair" ) ); + try { + UIManager.setLookAndFeel( + UIManager.getSystemLookAndFeelClassName() + ); + } catch ( Exception ex ) { + // $FALL-THROUGH$ + } + } + + /** + * Register the fonts provided with Llanfair with its environment. + */ + private void registerFonts() { + InputStream fontFile = RESOURCES.getStream( "digitalism.ttf" ); + try { + Font digitalism = Font.createFont( Font.TRUETYPE_FONT, fontFile ); + GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont( + digitalism + ); + } catch ( Exception ex ) { + // $FALL-THROUGH$ + } + } + + /** + * Writes all values from the {@code Language} enum in a property file. + * This method will append all the newly defined entries to the list of + * already existing values. + */ + private static void dumpLocalization() { + try { + String iso = Locale.getDefault().getLanguage(); + FileWriter fw = new FileWriter( "language_" + iso + ".properties" ); + for ( Language lang : Language.values() ) { + String old = RESOURCES.getString( lang.name() ); + fw.write( lang.name() + " = " ); + if ( old != null ) { + fw.write( old ); + } + fw.write( "\n" ); + } + fw.close(); + } catch ( IOException ex ) { + // $FALL-THROUGH$ + } + } + + /** + * When the locale changes, we first ask the resources to reload the locale + * dependent resources and pass the event to the GUI. + */ + @Override public void localeChanged( LocaleEvent event ) { + RESOURCES.defaultLocaleChanged(); + if ( runPane != null ) { + runPane.processLocaleEvent( event ); + } + MenuItem.localeChanged( event ); + } + + /** + * If we do not ignore the native inputs, register the input and invokes + * a new thread to treat the input whenever possible without hogging the + * main thread. + */ + @Override public void nativeKeyPressed( final NativeKeyEvent event ) { + int keyCode = event.getKeyCode(); + boolean hotkeysEnabler = ( keyCode == Settings.KEY_LOCK.get() ); + + if ( !ignoresNativeInputs() || hotkeysEnabler ) { + SwingUtilities.invokeLater( new Runnable() { + @Override public void run() { + actions.process( event ); + } + } ); + } + } + + @Override public void nativeKeyReleased( NativeKeyEvent event ) {} + + @Override public void nativeKeyTyped( NativeKeyEvent event ) {} + + /** + * A property change event might be fired from either the settings + * singleton or the run itself. In either case, we propagate the event to + * our children and update ourself with the new value of the given property. + */ + @Override public void propertyChange( PropertyChangeEvent event ) { + runPane.processPropertyChangeEvent( event ); + String property = event.getPropertyName(); + + if ( Run.STATE_PROPERTY.equals( property ) ) { + MenuItem.setActiveState( run.getState() ); + } else if ( Settings.GNR_ATOP.equals( property ) ) { + setAlwaysOnTop( Settings.GNR_ATOP.get() ); + } else if (Settings.HST_ROWS.equals(property) + || Settings.GPH_SHOW.equals(property) + || Settings.FOO_SHOW.equals(property) + || Settings.FOO_SPLT.equals(property) + || Settings.COR_ICSZ.equals(property) + || Settings.GNR_ACCY.equals(property) + || Settings.HDR_TTLE.equals(property) + || Settings.HDR_GOAL.equals(property) + || Settings.HST_DLTA.equals(property) + || Settings.HST_SFNT.equals(property) + || Settings.HST_TFNT.equals(property) + || Settings.HST_LIVE.equals(property) + || Settings.HST_MERG.equals(property) + || Settings.HST_BLNK.equals(property) + || Settings.HST_ICON.equals(property) + || Settings.HST_ICSZ.equals(property) + || Settings.HST_LINE.equals(property) + || Settings.COR_NAME.equals(property) + || Settings.COR_SPLT.equals(property) + || Settings.COR_SEGM.equals(property) + || Settings.COR_BEST.equals(property) + || Settings.COR_ICON.equals(property) + || Settings.COR_TFNT.equals(property) + || Settings.COR_SFNT.equals(property) + || Settings.COR_STMR.equals(property) + || Settings.FOO_BEST.equals(property) + || Settings.FOO_DLBL.equals(property) + || Settings.FOO_VERB.equals(property) + || Settings.FOO_LINE.equals(property) + || Run.NAME_PROPERTY.equals(property)) { + setPreferredSize(null); + pack(); + } + } + + /** + * When the run's table of segments is updated, we ask the main panel to + * update itself accordingly and repack the frame as its dimensions may + * have changed. + */ + @Override public void tableChanged( TableModelEvent event ) { + runPane.processTableModelEvent( event ); + // No need to recompute the size if we receive a HEADER_ROW UPDATE + // as we only use them when a segment is moved up or down and when + // the user cancel any changes made to his run. + if ( event.getType() == TableModelEvent.UPDATE + && event.getFirstRow() == TableModelEvent.HEADER_ROW ) { + setPreferredSize( preferredSize ); + } else { + setPreferredSize( null ); + } + pack(); + } + + /** + * When the user scrolls the mouse wheel, we update the graph's scale to + * zoom in or out depending on the direction of the scroll. + */ + @Override public void mouseWheelMoved( MouseWheelEvent event ) { + int rotations = event.getWheelRotation(); + float percent = Settings.GPH_SCAL.get(); + if ( percent == 0.5F ) { + percent = 1.0F; + rotations--; + } + float newValue = Math.max( 0.5F, percent + rotations ); + Settings.GPH_SCAL.set( newValue ); + } + + /** + * When the user clicks on the mouse's right-button, we bring up the + * context menu at the click's location. + */ + @Override public void mousePressed( MouseEvent event ) { + super.mousePressed( event ); + if ( SwingUtilities.isRightMouseButton( event ) ) { + popupMenu.show( this, event.getX(), event.getY() ); + } + } + + /** + * Whenever the frame is being disposed of, we save the settings and + * unregister the native hook of {@code JNativeHook}. + */ + @Override public void windowClosed( WindowEvent event ) { + Settings.save(); + GlobalScreen.unregisterNativeHook(); + } + + @Override public void windowClosing(WindowEvent event) {} + + @Override public void windowOpened(WindowEvent event) {} + + @Override public void windowActivated(WindowEvent event) {} + + @Override public void windowDeactivated(WindowEvent event) {} + + @Override public void windowIconified(WindowEvent event) {} + + @Override public void windowDeiconified(WindowEvent event) {} + + /** + * Action events are fired by clicking on the entries of the context menu. + */ + @Override public synchronized void actionPerformed( final ActionEvent ev ) { + MenuItem source = ( MenuItem ) ev.getSource(); + + SwingUtilities.invokeLater( new Runnable() { + @Override public void run() { + actions.process( ev ); + } + } ); + + if (source.equals(MenuItem.EDIT)) { + + } else if (source.equals(MenuItem.RESIZE_DEFAULT)) { + setPreferredSize(null); + pack(); + + } else if (source.equals(MenuItem.RESIZE_PREFERRED)) { + setPreferredSize(preferredSize); + pack(); + } + } + + /** + * Sets the persistent behavior of the application and its components. + * + * @throws IllegalStateException if JNativeHook cannot be registered. + */ + private void setBehavior() { + try { + GlobalScreen.registerNativeHook(); + } catch (NativeHookException e) { + throw new IllegalStateException("cannot register native hook"); + } + setAlwaysOnTop(Settings.GNR_ATOP.get()); + addWindowListener(this); + addMouseWheelListener(this); + Settings.addPropertyChangeListener(this); + GlobalScreen.getInstance().addNativeKeyListener(this); + } + + /** + * Initializes the right-click context menu. + */ + private void setMenu() { + popupMenu = MenuItem.getPopupMenu(); + MenuItem.addActionListener( this ); + MenuItem.populateRecentlyOpened(); + } + +} diff --git a/org/fenix/llanfair/MenuItem.java b/org/fenix/llanfair/MenuItem.java new file mode 100644 index 0000000..e53e858 --- /dev/null +++ b/org/fenix/llanfair/MenuItem.java @@ -0,0 +1,231 @@ +package org.fenix.llanfair; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.File; +import java.util.Arrays; +import java.util.List; +import javax.swing.Icon; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JSeparator; +import javax.swing.event.EventListenerList; +import org.fenix.utils.locale.LocaleEvent; +import org.fenix.llanfair.Run.State; +import org.fenix.llanfair.config.Settings; + +/** + * Enumerates the menu items available in the right-click context menu of + * Llanfair. An item knows how to look but does not know how to behave. The + * behavior is abstracted in the {@code Actions} class. + * + * @author Xavier "Xunkar" Sencert + * @version 1.1 + */ +enum MenuItem implements ActionListener { + + EDIT( false, State.NULL, State.READY ), + NEW( true, State.NULL, State.READY, State.STOPPED ), + OPEN( false, State.NULL, State.READY, State.STOPPED ), + OPEN_RECENT( false, State.NULL, State.READY, State.STOPPED ), + IMPORT( false, State.NULL, State.READY, State.STOPPED ), + SAVE( false, State.READY, State.STOPPED ), + SAVE_AS( true, State.READY ), + RESET( true, State.ONGOING, State.STOPPED, State.PAUSED ), + LOCK( false, State.NULL, State.READY, State.STOPPED, State.ONGOING ), + UNLOCK( false, State.NULL, State.READY, State.STOPPED, State.ONGOING ), + RESIZE_DEFAULT( false, State.NULL, State.READY ), + RESIZE_PREFERRED( true, State.NULL, State.READY ), + SETTINGS( true, State.NULL, State.READY, State.STOPPED ), + ABOUT( true, State.NULL, State.READY, State.STOPPED, State.ONGOING ), + EXIT( false, State.NULL, State.READY, State.STOPPED, State.ONGOING ); + + /** + * Static list of listeners that listen to all the menu items. Whenever + * one item fires an {@code ActionEvent}, the type statically fires an + * other {@code ActionEvent} with the {@code MenuItem} enumerate as the + * source component. + */ + private static EventListenerList listeners = new EventListenerList(); + + private static final int MAX_FILES = 5; + private static final int TRUNCATE = 30; + + private boolean isEndOfGroup; + private List activeStates; + private JMenuItem menuItem; + + /** + * Internal constructor used to set the attributes. Only called by the + * enum type itself. + * + * @param isEndOfGroup indicates if this item is a group ender + * @param activeStates list of active run states of this item + */ + private MenuItem( boolean isEndOfGroup, Run.State... activeStates ) { + this.isEndOfGroup = isEndOfGroup; + this.activeStates = Arrays.asList( activeStates ); + + if ( name().equals( "OPEN_RECENT" ) ) { + menuItem = new JMenu( toString() ); + } else { + menuItem = new JMenuItem( toString() ); + } + Icon icon = Llanfair.getResources().getIcon( "jmi/" + name() + ".png" ); + menuItem.setIcon( icon ); + menuItem.addActionListener( this ); + // LOCK & UNLOCK mask each other and Llanfair always start unlocked + if ( name().equals( "UNLOCK" ) ) { + menuItem.setVisible( false ); + } + } + + /** + * Returns a popup menu composed of all the menu items. A separator is + * inserted after each item which are indicated as end of their group. + * + * @return a popup menu containing every menu item + */ + static JPopupMenu getPopupMenu() { + JPopupMenu menu = new JPopupMenu(); + for ( MenuItem item : values() ) { + menu.add( item.menuItem ); + if ( item.isEndOfGroup ) { + menu.add( new JSeparator() ); + } + } + return menu; + } + + /** + * Enables or disables every menu items depending on the given run state. + * If this state is present in the list of active states for an item, then + * its GUI component is enabled, else it is disabled. + * + * @param state the current run state, cannot be {@code null} + */ + static void setActiveState( Run.State state ) { + if ( state == null ) { + throw new IllegalArgumentException( "Null run state" ); + } + for ( MenuItem item : values() ) { + item.menuItem.setEnabled( item.activeStates.contains( state ) ); + } + } + + /** + * Registers the given {@code ActionListener} to the list of listeners + * interested in capturing events from every menu items. + * + * @param listener the action listener to register + */ + static void addActionListener( ActionListener listener ) { + listeners.add( ActionListener.class, listener ); + } + + /** + * Callback to invoke whenever a file is opened. This method will sort the + * recent files menu to put the recently opened file at the top. + * + * @param path the name of the recently opened file + */ + static void recentlyOpened( String path ) { + assert ( path != null ); + List recentFiles = Settings.GNR_RCNT.get(); + + if ( recentFiles.contains( path ) ) { + recentFiles.remove( path ); + } + recentFiles.add( 0, path ); + + if ( recentFiles.size() > MAX_FILES ) { + recentFiles.remove( MAX_FILES ); + } + Settings.GNR_RCNT.set( recentFiles ); + populateRecentlyOpened(); + } + + /** + * Fills the {@code OPEN_RECENT} item with the list of recently opened + * files. If {@code MAX_FILES} is somehow lower than the recent files list + * length, the overflowing files are removed. + */ + static void populateRecentlyOpened() { + List recentFiles = Settings.GNR_RCNT.get(); + for ( int i = MAX_FILES; i < recentFiles.size(); i++ ) { + recentFiles.remove( i - 1 ); + } + OPEN_RECENT.menuItem.removeAll(); + for ( String fileName : Settings.GNR_RCNT.get() ) { + String text = fileName; + int index = text.lastIndexOf( File.separatorChar ); + if ( index == -1 ) { + int length = text.length(); + int start = length - Math.min( length, TRUNCATE ); + text = text.substring( start ); + if ( start == 0 ) { + text = "[...]" + text; + } + } else { + text = text.substring( index + 1 ); + } + JMenuItem jmi = new JMenuItem( text ); + jmi.setName( "RECENT" + fileName ); + jmi.addActionListener( OPEN_RECENT ); + OPEN_RECENT.menuItem.add( jmi ); + } + } + + /** + * Returns the localized name of this menu item. + */ + @Override public String toString() { + return Language.valueOf( "menuItem_" + name().toLowerCase() ).get(); + } + + /** + * When a GUI component fires an action event, capture it and fire it + * back, setting the source as the enumerate value instead of the GUI + * component. + */ + @Override public void actionPerformed( ActionEvent event ) { + Object source = event.getSource(); + + if ( source.equals( LOCK.menuItem ) ) { + LOCK.menuItem.setVisible( false ); + UNLOCK.menuItem.setVisible( true ); + } else if ( source.equals( UNLOCK.menuItem ) ) { + LOCK.menuItem.setVisible( true ); + UNLOCK.menuItem.setVisible( false ); + } + + JMenuItem jmi = ( JMenuItem ) source; + String name = jmi.getName(); + ActionListener[] als = listeners.getListeners( ActionListener.class ); + + if ( name != null && name.startsWith( "RECENT" ) ) { + event = new ActionEvent( + this, ActionEvent.ACTION_PERFORMED, name.substring( 6 ) + ); + } else { + event = new ActionEvent( + this, ActionEvent.ACTION_PERFORMED, jmi.getText() + ); + } + for ( ActionListener al : als ) { + al.actionPerformed( event ); + } + } + + /** + * When the locale changes, every menu item must be updated to enforce the + * new locale setting. + */ + public static void localeChanged( LocaleEvent event ) { + for ( MenuItem item : values() ) { + item.menuItem.setText( "" + item ); + } + } + +} diff --git a/org/fenix/llanfair/Run.java b/org/fenix/llanfair/Run.java new file mode 100644 index 0000000..1cd7d2b --- /dev/null +++ b/org/fenix/llanfair/Run.java @@ -0,0 +1,1151 @@ +package org.fenix.llanfair; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.Icon; +import javax.swing.event.TableModelListener; +import javax.swing.table.TableModel; +import org.fenix.llanfair.Language; +import org.fenix.llanfair.config.Settings; + +import org.fenix.utils.TableModelSupport; +import org.fenix.utils.config.Configuration; + + +/** + * Modélise une course comportant un certain nombre de segments. Une fois une + * course définie avec des objectifs chiffrés à chaque segment, il est possible + * de démarrer la course. À chaque fois qu’un segment est passé, on réalise un + * split, enregistrant le nouveau temps réalisé. Différentes méthodes + * sont disponibles afin de comparer le temps réalisé avec le temps objectif. + * De plus, {@code Run} implémente {@link TableModel} dans lequel chaque segment + * est une ligne. + * + * @author Xavier "Xunkar" Sencert + * @see TableModel + * @see Segment + */ +public class Run implements TableModel, Serializable { + + // -------------------------------------------------------------- CONSTANTS + + /** + * The serial version identifier used to determine the compatibility of the + * different serialized versions of this type. This identifier must change + * when modifications that break backward-compatibility are made to the + * type. + */ + public static final long serialVersionUID = 1010L; + + public static final int MAX_COUNTERS = 4; + + /** + * Identifier for the column containing the segment’s icon. + */ + public static final int COLUMN_ICON = 0; + + /** + * Identifier for the column containing the segment’s name. + */ + public static final int COLUMN_NAME = 1; + + /** + * Identifier for the column containing the segment’s run time. + */ + public static final int COLUMN_TIME = 2; + + /** + * Identifier for the column containing the segment’s segment time. + */ + public static final int COLUMN_SEGMENT = 3; + + /** + * Identifier for the column containing the segment’s best segment time. + */ + public static final int COLUMN_BEST = 4; + + /** + * Number of columns displayed in the table of segments. + */ + private static final int COLUMN_COUNT = 5; + + /** + * Identifier for the bean property name of the run. + */ + public static final String NAME_PROPERTY = "run.name"; + + /** + * Identifier for the bean property state of the run. + */ + public static final String STATE_PROPERTY = "run.state"; + + /** + * Identifier for the bean property current segment of the run. + */ + public static final String CURRENT_SEGMENT_PROPERTY = "run.currentSegment"; + + public static final String GOAL_PROPERTY = "run.goal"; + + public static final String COUNTER_VALUE_PROPERTY = "run.counters.value"; + + public static final String COUNTER_ADD_PROPERTY = "run.counters.add"; + + public static final String COUNTER_REMOVE_PROPERTY = "run.counters.remove"; + + public static final String COUNTER_EDIT_PROPERTY = "run.counters.edit"; + + // ------------------------------------------------------------- ATTRIBUTES + + /** + * The name of the run. + */ + private String name; + + /** + * Current state of the run. Transient as a deserialized run will always + * start in {@link State#READY}. + */ + private transient State state; + + /** + * Backup copy of the run’s state. Transient as it is only used during + * editing of the run. + */ + private transient State stateBackup; + + /** + * List of all the segments contained within this run. + */ + private List segments; + + /** + * Backup copy of the segments’ list. Necessary to buffer edits from + * {@code JTable}s and revert to the original state in case the user + * cancels. + */ + private transient List segmentsBackup; + + /** + * Index of the segment being currently run. Only represents a segment when + * the run is {@link State#ONGOING}. + */ + private transient int current; + + /** + * Number of milliseconds on the clock when the run started. + */ + private transient long startTime; + + /** + * Delegate handling {@code PropertyChangeEvent}s. + */ + private transient PropertyChangeSupport pcSupport; + + /** + * Delegate handling {@code TableModelEvent}s. + */ + private transient TableModelSupport tmSupport; + + private String goal; + + private boolean segmented; + + private List counters; + + private Configuration configuration; + + // ----------------------------------------------------------- CONSTRUCTORS + + /** + * Creates a new run of given name. This run has no segments and so begins + * in the state {@link State#NULL}. + * + * @param name - the name of the run + */ + public Run(String name) { + if (name == null) { + throw new NullPointerException("null run name"); + } + this.name = name; + segments = new ArrayList(); + goal = ""; + segmented = false; + counters = new ArrayList(); + initializeTransients(); + } + + /** + * Creates a default run without any segments and a default placeholder + * run name. This new run starts in the state {@code State.NULL}. + */ + public Run() { + this("" + Language.UNTITLED); + } + + // ---------------------------------------------------------------- GETTERS + + /** + * Returns the name of the run. + * + * @return the name of the run. + */ + public String getName() { + return name; + } + + public String getGoal() { + return goal; + } + + public boolean isSegmented() { + return segmented; + } + + /** + * Returns the current state of the run + * + * @return the current state of the run. + */ + public State getState() { + return state; + } + + /** + * Returns the number of milliseconds on the clock when the run started. + * + * @return the run’s start time stamp. + */ + public long getStartTime() { + return startTime; + } + + public Counters getCounter(int index) { + if (index < 0 || index >= MAX_COUNTERS) { + throw new IllegalArgumentException("illegal counter id " + index); + } + return counters.get(index); + } + + /** + * Returns the index of the segment being currently run. Only represents a + * segment when the run is {@link State#ONGOING}. + * + * @return the current segment index. + */ + public int getCurrent() { + return current; + } + + /** + * Returns the index of the previously runned segment. Only represents a + * segment if {@link #hasPreviousSegment()}. + * + * @return the previous segment index. + */ + public int getPrevious() { + return current - 1; + } + + /** + * Indicates wether or not a previous segment is available. This is the case + * if and only if the run is {@link State#ONGOING} or {@link State#STOPPED} + * and we currently are on the second segment of farther down the run. + * + * @return wether a previous segment is available or not. + */ + public boolean hasPreviousSegment() { + return current > 0; + } + + /** + * Returns the segment of given index. The index must be within the range + * {@code [0..getSegmentCount()[}. + * + * @param segmentIndex - the index of the segment to return. + * @return the segment of given index. + */ + public Segment getSegment(int segmentIndex) { + return segments.get(segmentIndex); + } + + /** + * Returns the run time of given type up to the given segment. Such a time + * can be {@code null} if the last segment has an undefined time. + * + * @param segmentIndex - index of the segment up to which get the time. + * @param type - one of the identifier. + * @return the run time up to the given segment + * @see Segment#getTime(int) + */ + public Time getTime(int segmentIndex, int type) { + return getTime(segmentIndex, type, true); + } + + /** + * Returns the run time of given type. Such a time can be {@code null} if + * the last segment has an undefined time. + * + * @param type - one of the identifier. + * @return the run time of given type. + * @see Segment#getTime(int) + */ + public Time getTime(int type) { + return getTime(getRowCount() - 1, type); + } + + public Time getTime(int segmentIndex, int type, boolean allowNull) { + if (segmentIndex < 0 || segmentIndex >= getRowCount()) { + return null; + } + if (type == Segment.DELTA) { + Time set = getTime(segmentIndex, Segment.SET); + Time live = getTime(segmentIndex, Segment.LIVE); + return (set == null ? null : Time.getDelta(live, set)); + } + Time runTime = new Time(); + for (int i = 0; i <= segmentIndex; i++) { + runTime.add(segments.get(i).getTime(type)); + } + Time lastSegmentTime = segments.get(segmentIndex).getTime(type); + return allowNull ? (lastSegmentTime == null ? null : runTime) : runTime; + } + + /** + * Returns a portion of the registered run time to which live times should + * be compared. This is equal to {@link Settings#COMPARE_PERCENT} percent of + * the whole run time. This allows for more visually detailed comparison. + * + * @return {@code P%} of {@code getTime(Segment.SET)} where {@code P} is + * the configured compare percent. + */ + public Time getCompareTime() { + int i = 2; + Time time = getTime(Segment.SET); + while (time == null) { + if (getRowCount() - i < 0) { + return Time.ZERO; + } + time = getTime(getRowCount() - i, Segment.SET); + i++; + } + long ms = time.getMilliseconds(); + float pc = Settings.GPH_SCAL.get(); + + return new Time((long) (ms * pc) / 100L); + } + + /** + * Returns the maximum height in pixels of the icons assigned to the + * segments of this run. Since icons are scaled down proportionnaly, the + * exact height can be lower than the configured icon size (if they are + * wider than high.) + * + * @return the exact maximum height of the icons of this run's segments. + */ + public int getMaxIconHeight() { + int max = 0; + for (Segment segment : segments) { + Icon icon = segment.getIcon(); + if (icon != null) { + max = Math.max(max, icon.getIconHeight()); + } + } + return max; + } + + /** + * Indicates wether the live run is better than the registered one or not, + * i.e. if we've established a new personal best. Cannot be {@code true} if + * the run hasn't reached the last segment. + * + * @return wether or not this run is a new personal best. + */ + public boolean isPersonalBest() { + if (current < getRowCount()) { + return false; + } + Time live = getTime(Segment.LIVE); + Time run = getTime(Segment.RUN); + return (live.compareTo(run) < 0); + } + + /** + * Indicates wehter or not any live segments time are better than their best + * registered time. Any time is always better than an undefined time. + * + * @return wether or not this run has new segments' best. + */ + public boolean hasSegmentsBest() { + for (int i = 0; i < current; i++) { + Segment segment = segments.get(i); + Time live = segment.getTime(Segment.LIVE); + Time best = segment.getTime(Segment.BEST); + + if (live != null && live.compareTo(best) < 0) { + return true; + } + } + return false; + } + + /** + * Returns wether of not the given segment live time is better than its + * registered time. If that time is undefined, we check using the sum of + * the live time from the previous non-null segment. + * + * @param index - the index of the segment. + * @return wether or not this segment live time is better. + */ + public boolean isBetterSegment(int index) { + Segment segment = getSegment(index); + Time set = segment.getTime(Segment.SET); + Time live = segment.getTime(Segment.LIVE); + + if (live == null) { + return false; + } + live = live.clone(); + + if (set != null) { + set = set.clone(); + } + + if (index > 0) { + for (int i = index - 1; i >= 0; i--) { + Segment prev = getSegment(i); + if (prev.getTime(Segment.SET) == null) { + live.add(prev.getTime(Segment.LIVE)); + } else { + break; + } + } + for (int i = index - 1; i >= 0; i--) { + Segment prev = getSegment(i); + if (prev.getTime(Segment.LIVE) == null) { + set.add(prev.getTime(Segment.SET)); + } else { + break; + } + } + } + return live.compareTo(set) < 0; + } + + public boolean isBestSegment(int index) { + Segment segment = getSegment(index); + Time set = segment.getTime(Segment.BEST); + Time live = segment.getTime(Segment.LIVE); + + if (live == null) { + return false; + } + live = live.clone(); + + if (set != null) { + set = set.clone(); + } + + if (index > 0) { + for (int i = index - 1; i >= 0; i--) { + Segment prev = getSegment(i); + if (prev.getTime(Segment.BEST) == null) { + live.add(prev.getTime(Segment.LIVE)); + } else { + break; + } + } + for (int i = index - 1; i >= 0; i--) { + Segment prev = getSegment(i); + if (prev.getTime(Segment.LIVE) == null) { + set.add(prev.getTime(Segment.BEST)); + } else { + break; + } + } + } + return live.compareTo(set) < 0; + } + + // ------------------------------------------------------ INHERITED GETTERS + + /** + * As specified by {@code TableModel}. + */ + public int getColumnCount() { + return COLUMN_COUNT; + } + + /** + * As specified by {@code TableModel}. + * Equivalent to the number of segments in the run. + */ + public int getRowCount() { + return segments.size(); + } + + /** + * As specified by {@code TableModel}. Always yield {@code true} as every + * information of every segment is editable. + */ + public boolean isCellEditable(int rowIndex, int columnIndex) { + return true; + } + + /** + * As specified by {@code TableModel}. + */ + public Class getColumnClass(int columnIndex) { + switch (columnIndex) { + case COLUMN_ICON: return Icon.class; + case COLUMN_NAME: return String.class; + case COLUMN_TIME: return Time.class; + case COLUMN_BEST: return Time.class; + case COLUMN_SEGMENT: return Time.class; + } + // Should not be reached. + return null; + } + + /** + * As specified by {@code TableModel}. + */ + public String getColumnName(int column) { + switch (column) { + case COLUMN_ICON: return "" + Language.ICON; + case COLUMN_NAME: return "" + Language.NAME; + case COLUMN_TIME: return "" + Language.TIME; + case COLUMN_BEST: return "" + Language.BEST; + case COLUMN_SEGMENT: return "" + Language.SEGMENT; + } + // Should not be reached. + return null; + } + + /** + * As specified by {@code TableModel}. + */ + public Object getValueAt(int row, int column) { + switch (column) { + case COLUMN_ICON: return getSegment(row).getIcon(); + case COLUMN_NAME: return getSegment(row).getName(); + case COLUMN_TIME: return getTime(row, Segment.RUN); + case COLUMN_BEST: return getSegment(row).getTime(Segment.BEST); + case COLUMN_SEGMENT: return getSegment(row).getTime(Segment.RUN); + } + // Should not be reached. + return null; + } + + // ---------------------------------------------------------------- SETTERS + + public T getSetting( String key ) { + return configuration.get( key ); + } + + public void putSetting( String key, Object value ) { + configuration.put( key, value ); + } + + public void addSettingChangeListener( PropertyChangeListener pcl ) { + configuration.addPropertyChangeListener( pcl ); + } + + public boolean containsSetting( String key ) { + return configuration.contains( key ); + } + + /** + * Sets the name of this run to the given string. + * + * @param name - the new name of the run. + */ + public void setName(String name) { + if (name == null) { + throw new NullPointerException("null run name"); + } + String old = this.name; + this.name = name; + pcSupport.firePropertyChange(NAME_PROPERTY, old, name); + } + + public void setSegmented(boolean segmented) { + this.segmented = segmented; + } + + public void setGoal(String goal) { + if (goal == null) { + throw new NullPointerException("null goal string"); + } + String old = this.goal; + this.goal = goal; + pcSupport.firePropertyChange(GOAL_PROPERTY, old, goal); + } + + /** + * Inserts the given segment at the end. If it's the first segment being + * added, the run becomes {@link State#READY}, meaning it can be started. + * + * @param segment - the segment to add to this run. + */ + public void addSegment(Segment segment) { + if (segment == null) { + throw new NullPointerException("null segment"); + } + int oldCount = getRowCount(); + segments.add(segment); + tmSupport.fireTableRowsInserted(oldCount, oldCount); + + if (oldCount == 0) { + state = State.READY; + pcSupport.firePropertyChange(STATE_PROPERTY, State.NULL, state); + } + } + + /** + * Removes the segment of given index from the this run table of segments. + * If we remove the last segment, the run becomes {@link State#NULL}. + * + * @param segmentIndex - the index of the segment to remove. + */ + public void removeSegment(int segmentIndex) { + setValueAt(null, segmentIndex, 2); + segments.remove(segmentIndex); + tmSupport.fireTableRowsDeleted(segmentIndex, segmentIndex); + + if (getRowCount() == 0) { + State old = state; + state = State.NULL; + pcSupport.firePropertyChange(STATE_PROPERTY, old, state); + } + } + + /** + * Removes the segment of given index and inserts it back one position + * lower. The segment effectively moves one position upward. + * + * @param segmentIndex - the index of the segment to move. + */ + public void moveSegmentUp(int segmentIndex) { + if (segmentIndex > 0) { + Segment segment = segments.get(segmentIndex); + segments.remove(segmentIndex); + segments.add(segmentIndex - 1, segment); + tmSupport.fireTableStructureChanged(); + } + } + + /** + * Removes the segment of given index and inserts it back one position + * higher. The segment effectively moves one position downward. + * + * @param segmentIndex - the index of the segment to move. + */ + public void moveSegmentDown(int segmentIndex) { + if (segmentIndex < getRowCount() - 1) { + Segment segment = segments.get(segmentIndex); + segments.remove(segmentIndex); + segments.add(segmentIndex + 1, segment); + tmSupport.fireTableStructureChanged(); + } + } + + /** + * Starts the race. The clock time is saved as the run start time and the + * current segment becomes the first segment of the run. + * + * @throws IllegalStateException if the run is on-going or null. + */ + public void start() { + if (state == null || state == State.ONGOING) { + throw new IllegalStateException("illegal state to start"); + } + startTime = System.nanoTime() / 1000000L; + current = 0; + state = State.ONGOING; + segments.get(current).setStartTime(startTime); + + pcSupport.firePropertyChange(STATE_PROPERTY, State.READY, state); + pcSupport.firePropertyChange(CURRENT_SEGMENT_PROPERTY, -1, 0); + } + + /** + * Makes a split, saving the elapsed time for the current segment and + * setting the next segment as current. If we were at the last segment, + * the run becomes {@link State#STOPPED}. + * + * @throws IllegalStateException if the run is not on-going. + */ + public void split() { + if (state != State.ONGOING) { + throw new IllegalStateException("run is not on-going"); + } + long stopTime = System.nanoTime() / 1000000L; + long segmentTime = stopTime - getSegment(current).getStartTime(); + current = current + 1; + + Time time = new Time(segmentTime); + segments.get(current - 1).setTime(time, Segment.LIVE); + + if (current == getRowCount()) { + stop(); + } else { + segments.get(current).setStartTime(stopTime); + } + pcSupport.firePropertyChange( + CURRENT_SEGMENT_PROPERTY, current - 1, current); + if (segmented && state == State.ONGOING && current > -1) { + pause(); + } + } + + /** + * If a run is on-going and a split has been made, this method will cancel + * it, reverting to the state before said split and discarding the + * established live time. + * + * @throws IllegalStateException if the run is not on-going. + */ + public void unsplit() { + if (state == State.NULL || state == State.READY) { + throw new IllegalStateException("illegal run state"); + } + if (current > 0) { + current = current - 1; + getSegment(current).setTime(null, Segment.LIVE); + + pcSupport.firePropertyChange( + CURRENT_SEGMENT_PROPERTY, current + 1, current); + + if (state == State.STOPPED) { + state = State.ONGOING; + pcSupport.firePropertyChange( + STATE_PROPERTY, State.STOPPED, state); + } + } + } + + public void pause() { + if (state != State.ONGOING) { + throw new IllegalStateException("run is not on-going"); + } + state = State.PAUSED; + long stopTime = System.nanoTime() / 1000000L; + long segmentTime = stopTime - getSegment(current).getStartTime(); + Time time = new Time(segmentTime); + segments.get(current).setTime(time, Segment.LIVE, true); + pcSupport.firePropertyChange(STATE_PROPERTY, State.ONGOING, state); + } + + public void resume() { + if (state != State.PAUSED) { + throw new IllegalStateException("run is not paused"); + } + state = State.ONGOING; + long stop = System.nanoTime() / 1000000L; + startTime = stop - getTime(current, Segment.LIVE, false).getMilliseconds(); + + Segment crt = getSegment(current); + crt.setStartTime(stop - crt.getTime(Segment.LIVE).getMilliseconds()); + + long cumulative = 0L; + for (int i = 0; i < current; i++) { + Segment iSeg = getSegment(i); + iSeg.setStartTime(startTime + cumulative); + cumulative += iSeg.getTime(Segment.LIVE).getMilliseconds(); + } + pcSupport.firePropertyChange(STATE_PROPERTY, State.PAUSED, state); + } + + /** + * Stops the current on-going run. + * + * @throws IllegalStateException if the is not on-going. + */ + public void stop() { + if (state != State.ONGOING) { + throw new IllegalStateException("run is not on-going"); + } + state = State.STOPPED; + pcSupport.firePropertyChange(STATE_PROPERTY, State.ONGOING, state); + } + + /** + * Resets the current run, discarding any live times and becoming once + * again {@link State#READY}. + */ + public void reset() { + for (Segment segment : segments) { + segment.setTime(null, Segment.LIVE); + } + current = -1; + startTime = 0L; + + State old = state; + state = State.READY; + pcSupport.firePropertyChange(STATE_PROPERTY, old, state); + } + + /** + * Skips the current segment, setting its live segment time as undefined + * and moving to the next segment. Since it is not possible to skip the + * last segment, a next one always exist. The next segment time will be + * defined as the delta between the start of the previous non-null segment + * and the split time of this segment. + */ + public void skip() { + if (current > - 1 && current < getRowCount() - 1) { + Segment crtSegment = getSegment(current); + long segmentStart = crtSegment.getStartTime(); + crtSegment.setTime(null, Segment.LIVE); + + current = current + 1; + getSegment(current).setStartTime(segmentStart); + + pcSupport.firePropertyChange( + CURRENT_SEGMENT_PROPERTY, current - 1, current); + } + } + + /** + * Overwrites the registered with the live times, using the following + * algorithm: if a live segment time is better than its registered best + * time, the best time is overwritten. If we are not doing a {@code partial} + * save and the run is complete (its end was naturally reached), the + * registered segment time is overwritten with the live segment time. + * + * @param partial - wether to only save best times or the whole run. + */ + public void saveLiveTimes(boolean partial) { + boolean over = (current == getRowCount()); + for (Segment segment : segments) { + Time live = segment.getTime(Segment.LIVE); + if (live == null) { + if (!partial && over) { + segment.setTime(null, Segment.RUN); + } + } else { + if (live.compareTo(segment.getTime(Segment.BEST)) < 0) { + segment.setTime(live, Segment.BEST); + } + if (!partial && over) { + segment.setTime(live, Segment.RUN); + } + } + } + } + + /** + * Creates a backup copy of this run table of segments in order to be able + * to revert any changes made by the user using direct-editing components. + */ + public void saveBackup() { + stateBackup = state; + segmentsBackup = new ArrayList(); + for (Segment segment : segments) { + segmentsBackup.add(segment.clone()); + } + } + + /** + * Reverts this run table of segments to its original state prior to the + * call to {@link #saveBackup()}. Does nothing if no backup has been + * created. After this call, the existing backup will be discarded. + */ + public void loadBackup() { + if (segmentsBackup != null) { + State old = state; + state = stateBackup; + segments = new ArrayList(); + for (Segment segment : segmentsBackup) { + segments.add(segment); + } + segmentsBackup = null; + tmSupport.fireTableStructureChanged(); + pcSupport.firePropertyChange(STATE_PROPERTY, old, state); + } + } + + /** + * Sets the split time of the given segment. If the new value is + * {@code null} (meaning the time is undefined,) the segment time and best + * time also becomes {@code null} and the previously set segment time is + * added to the next non-null segment time. If it is not, the delta between + * the new and the old value is added to the next non-null segment and the + * new segment time is computed using the previous non-null segment time. + * + * @param index - the index of the segment to set a split time to. + * @param time - the new split time. + */ + public void setSplitTime(int index, Time time) { + Time oldTime = getTime(index, Segment.RUN); + + if (time == null) { + setSegmentTime(index, null); + } else { + // Compute the time added to/removed from the segment. + if (oldTime == null) { + if (index > 0) { + oldTime = getTime(index - 1, Segment.RUN); + } + } + Time delta = Time.getDelta(oldTime, time); + Time pTime = new Time(); + + // Find the first previous non-null segment. + for (int i = index - 1; i >= 0; i--) { + if (getSegment(i).getTime(Segment.RUN) != null) { + pTime = getTime(i, Segment.RUN); + break; + } + } + // If a next non-null segment exist possess a split time + // inferior to the split time we are defining, we add + // the delta to preserve consistency. + for (int i = index + 1; i < getRowCount(); i++) { + Segment nSegment = getSegment(i); + if (nSegment.getTime(Segment.RUN) != null) { + Time nTime = getTime(i, Segment.RUN); + if (time.compareTo(nTime) < 0 + && time.compareTo(pTime) > 0) { + nSegment.getTime(Segment.RUN).add(delta); + } + break; + } + } + time = Time.getDelta(time, pTime); + Segment segment = getSegment(index); + segment.setTime(time, Segment.RUN); + + if (time.compareTo(segment.getTime(Segment.BEST)) < 0) { + segment.setTime(time, Segment.BEST); + } + } + } + + /** + * Registers the given listener as a new {@code PropertyChangeListener} for + * this run. + * + * @param pcl - the listener to register. + */ + public void addPropertyChangeListener(PropertyChangeListener pcl) { + pcSupport.addPropertyChangeListener(pcl); + } + + /** + * Removes the given listener from the list of registered + * {@code PropertyChangeListener} of this run. + * + * @param pcl - the listener to register. + */ + public void removePropertyChangeListener(PropertyChangeListener pcl) { + pcSupport.removePropertyChangeListener(pcl); + } + + // ------------------------------------------------------ INHERITED SETTERS + + /** + * Registers the given listener as a new {@code TableModelListener} for + * this run table of segments. + * + * @param tml - the listener to register. + */ + public void addTableModelListener(TableModelListener tml) { + tmSupport.addTableModelListener(tml); + } + + /** + * Removes the given listener from the list of registered + * {@code TableModelListener} of this run. + * + * @param tml - the listener to register. + */ + public void removeTableModelListener(TableModelListener tml) { + tmSupport.removeTableModelListener(tml); + } + + /** + * As specified by {@code TableModel}. Sets the value of given column for + * the segment of given row index. + */ + public void setValueAt(Object value, int row, int column) { + Segment segment = getSegment(row); + switch (column) { + case COLUMN_ICON: + segment.setIcon((Icon) value); + tmSupport.fireTableCellUpdated(row, column); + break; + + case COLUMN_NAME: + segment.setName((String) value); + tmSupport.fireTableCellUpdated(row, column); + break; + + case COLUMN_TIME: + Time newTime = (Time) value; + setSplitTime(row, newTime); + tmSupport.fireTableDataChanged(); + break; + + case COLUMN_SEGMENT: + setSegmentTime(row, (Time) value); + tmSupport.fireTableDataChanged(); + break; + + case COLUMN_BEST: + newTime = (Time) value; + Time runTime = segment.getTime(Segment.RUN); + if (newTime == null || newTime.compareTo(runTime) > 0) { + segment.setTime(runTime, Segment.BEST); + } else { + segment.setTime(newTime, Segment.BEST); + } + tmSupport.fireTableDataChanged(); + break; + } + } + + // -------------------------------------------------------------- UTILITIES + + /** + * Sets the segment time of the given segment. If the segment time is + * better than the best time, the best time is updated accordingly. + * + * @param index - the index of the segment to set a split time to. + * @param time - the new split time. + */ + private void setSegmentTime(int index, Time time) { + if (time != null && time.compareTo(Time.ZERO) <= 0) { + throw new IllegalArgumentException("" + Language.ILLEGAL_TIME); + } + Segment segment = segments.get(index); + Time best = segment.getTime(Segment.BEST); + Time old = segment.getTime(Segment.RUN); + int rowCount = getRowCount(); + + if (time == null) { + // The old segment time of the newly undefined segment goes + // to the next non-null segment to preserve split times + // consistency. + for (int i = index + 1; i < rowCount; i++) { + Time nTime = getSegment(i).getTime(Segment.RUN); + if (nTime != null) { + nTime.add(old); + break; + } + } + + } else if (old == null) { + for (int i = index + 1; i < rowCount; i++) { + Segment nSeg = getSegment(i); + Time nTime = nSeg.getTime(Segment.RUN); + if (nTime != null) { + if (nTime.compareTo(time) <= 0) { + throw new IllegalArgumentException( + "" + Language.ILLEGAL_SEGMENT_TIME); + } + nSeg.setTime(Time.getDelta(nTime, time), Segment.RUN); + break; + } + } + + } + segment.setTime(time, Segment.RUN); + + if (time != null) { + if (time.compareTo(best) < 0) { + segment.setTime(time, Segment.BEST); + } + } + } + + /** + * Initialize all transient fields. + */ + private void initializeTransients() { + pcSupport = new PropertyChangeSupport(this); + tmSupport = new TableModelSupport(this); + segmentsBackup = null; + stateBackup = null; + state = getRowCount() > 0 ? State.READY : State.NULL; + current = -1; + startTime = 0L; + + if (goal == null) { + goal = ""; + } + if (counters == null) { + counters = new ArrayList(); + } + if ( configuration == null ) { + configuration = new Configuration(); + } + } + + /** + * Deserialization process. Redefined to initialize transients fields upon + * deserialization. + */ + private void readObject(ObjectInputStream in) + throws IOException, ClassNotFoundException { + in.defaultReadObject(); + initializeTransients(); + } + + // ---------------------------------------------------------- INTERNAL TYPE + + /** + * Enumeration of a run possible state. + * + * @author Xavier "Xunkar" Sencert + */ + public static enum State { + + // ---------------------------------------------------- ENUMERATES + + /** + * The run cannot start, probably because there are no segments. + */ + NULL, + + /** + * The run is ready to start. This state should be achieved upon + * opening a run and resetting it. + */ + READY, + + /** + * The run is being runned. A current segment now exists. + */ + ONGOING, + + /** + * The run was stopped, either naturally by making the last split or + * voluntarily by the user. + */ + STOPPED, + + /** + * The run was paused by the user. The timer should not be running + * while a run is paused. + */ + PAUSED; + + // ----------------------------------------------------- CONSTANTS + + /** + * The serial version identifier used to determine the compatibility of + * the different serialized versions of this type. This identifier must + * change when modifications that break backward-compatibility are made + * to the type. + */ + private static final long serialVersionUID = 1000L; + } + +} diff --git a/org/fenix/llanfair/Segment.java b/org/fenix/llanfair/Segment.java new file mode 100644 index 0000000..7b6e070 --- /dev/null +++ b/org/fenix/llanfair/Segment.java @@ -0,0 +1,328 @@ +package org.fenix.llanfair; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import javax.swing.Icon; +import org.fenix.llanfair.Language; +import org.fenix.llanfair.config.Settings; + +import org.fenix.utils.Images; + +/** + * Represents a portion of a run. As such, a segment is associated to a + * registered time as well as a best time and a live time set on the fly by + * the run owning the segment. + * + * @author Xavier "Xunkar" Sencert + */ +public class Segment implements Cloneable, Serializable { + + // -------------------------------------------------------------- CONSTANTS + + /** + * The serial version identifier used to determine the compatibility of the + * different serialized versions of this type. This identifier must change + * when modifications that break backward-compatibility are made to the + * type. + */ + private static final long serialVersionUID = 1001L; + + /** + * Array of legit display sizes for the segments’ icons. + */ + public static final Integer[] ICON_SIZES = new Integer[] { + 16, 24, 32, 40, 48, 56, 64 + }; + + /** + * Maximum size allowed for the segment’s icons. When setting an icon for + * a segment, it will be scaled down to that size if it’s bigger, but will + * not be scaled up if it’s smaller. + */ + public static final int ICON_MAX_SIZE = ICON_SIZES[ICON_SIZES.length - 1]; + + /** + * Identifier for the time of the segment as defined by the currently set + * compare method. + */ + public static final int SET = 0; + + /** + * Identifier for the registered time of the segment. + */ + public static final int RUN = 1; + + /** + * Identifier for the best ever registered time of the segment. + */ + public static final int BEST = 2; + + /** + * Identifier for the live time realized on this segment. + */ + public static final int LIVE = 3; + + /** + * Identifier for the delta between the live time and the time as defined + * by the currently set compare method, i.e. {@code LIVE - SET}. + */ + public static final int DELTA = 4; + + /** + * Identifier for the delta between the live time and the registered time + * of the segment, i.e. {@code LIVE - RUN}. + */ + public static final int DELTA_RUN = 5; + + /** + * Identifier for the delta between the live time and the best ever + * registered time of the segment, i.e. {@code LIVE - BEST}. + */ + public static final int DELTA_BEST = 6; + + // ------------------------------------------------------------- ATTRIBUTES + + /** + * Name of the segment. + */ + private String name; + + /** + * Icon associated with this segment. Can be {@code null} if no icon is to + * be displayed. + */ + private Icon icon; + + /** + * Registered time for this segment during the best run. + */ + private Time runTime; + + /** + * Best time ever registered for this segment. + */ + private Time bestTime; + + /** + * Live time realized on this segment during a run. This value is never + * saved as is but can overwrite {@code runTime} or {@code bestTime}. + */ + private transient Time liveTime; + + /** + * Number of milliseconds on the clock when the segment started. + */ + private transient long startTime; + + // ----------------------------------------------------------- CONSTRUCTORS + + /** + * Creates a new segment of given name and undefined times. + * + * @param name - the name of the segment. + */ + public Segment(String name) { + if (name == null) { + throw new NullPointerException("null segment name"); + } + this.name = name; + icon = null; + runTime = null; + bestTime = null; + initializeTransients(); + } + + /** + * Creates a default segment with a default name and undefined times. + */ + public Segment() { + this("" + Language.UNTITLED); + } + + // ---------------------------------------------------------------- GETTERS + + /** + * Returns the name of the segment. + * + * @return the name of the segment. + */ + public String getName() { + return name; + } + + /** + * Returns the icon associated with this segment. Can be {@code null}. + * + * @return the icon of the segment or {@code null}. + */ + public Icon getIcon() { + return icon; + } + + /** + * Returns the number of milliseconds on the clock when the segment started. + * + * @return the start time of this segment. + */ + public long getStartTime() { + return startTime; + } + + /** + * Returns the given type of time for this segment. + * + * @param type - one of the type identifier. + * @return the segment time of given type. + */ + public Time getTime(int type) { + switch (type) { + case BEST: + return bestTime; + + case LIVE: + return liveTime; + + case RUN: + return runTime; + + case DELTA_RUN: + if (runTime == null) { + return null; + } + return Time.getDelta(liveTime, runTime); + + case DELTA_BEST: + if (bestTime == null) { + return null; + } + return Time.getDelta(liveTime, bestTime); + + case DELTA: + Time time = getTime(); + return (time == null ? null : Time.getDelta(liveTime, time)); + + default: + return getTime(); + } + } + + /** + * As specified by {@code Cloneable}, returns a deep copy of the segment. + */ + public Segment clone() { + Segment segment = new Segment(name); + segment.icon = icon; + segment.runTime = (runTime == null ? null : runTime.clone()); + segment.bestTime = (bestTime == null ? null : bestTime.clone()); + segment.liveTime = (liveTime == null ? null : liveTime.clone()); + segment.startTime = startTime; + return segment; + } + + // ---------------------------------------------------------------- SETTERS + + /** + * Sets the name of the segment to the given string. + * + * @param name - the new name of the segment. + */ + public void setName(String name) { + if (name == null) { + throw new NullPointerException("null name"); + } + this.name = name; + } + + /** + * Sets the current icon of this segment. Can be {@code null} to remove + * the current icon or indicate that no icon should be used. The icon wil + * be scale down to {@code ICON_MAX_SIZE} if it’s bigger. + * + * @param icon - the new icon for this segment. + */ + public void setIcon(Icon icon) { + if (icon == null) { + this.icon = null; + } else { + this.icon = Images.rescale(icon, ICON_MAX_SIZE); + } + } + + /** + * Sets the number of milliseconds on the clock when the segment started. + * Should only be called by the run owning this segment. + * + * @param startTime - the starting time of this segment. + * @throws IllegalArgumentException if the time is negative. + */ + void setStartTime(long startTime) { + if (startTime < 0L) { + throw new IllegalArgumentException("negative start time"); + } + this.startTime = startTime; + } + + /** + * Sets the given type of time to the new value. Note that some type of + * times cannot be set (such as {@code DELTA}s.) The new value can be + * {@code null} to indicate undefined times. + * + * @param time - the new time value for the given type. + * @param type - one of the type identifier. + * @throws IllegalArgumentException if the new time value is lower than or + * equal to zero. + */ + public void setTime(Time time, int type, boolean bypass) { + if (!bypass) { + if (time != null && time.compareTo(Time.ZERO) <= 0) { + throw new IllegalArgumentException("" + Language.ILLEGAL_TIME); + } + } + switch (type) { + case BEST: bestTime = time; break; + case LIVE: liveTime = time; break; + case RUN: runTime = time; break; + } + } + + public void setTime(Time time, int type) { + setTime(time, type, false); + } + + // -------------------------------------------------------------- UTILITIES + + /** + * Initialize all transient fields. + */ + private void initializeTransients() { + liveTime = null; + startTime = 0L; + } + + /** + * Returns the time of this segment as specified by the currently set + * compare method. + * + * @return the time as defined by the current compare method. + */ + private Time getTime() { + switch (Settings.GNR_COMP.get()) { + case BEST_OVERALL_RUN: return runTime; + case SUM_OF_BEST_SEGMENTS: return bestTime; + } + // Should not be reached. + return null; + } + + /** + * Deserialization process. Redefined to initialize transients fields upon + * deserialization. + */ + private void readObject(ObjectInputStream in) + throws IOException, ClassNotFoundException { + in.defaultReadObject(); + initializeTransients(); + } + +} diff --git a/org/fenix/llanfair/Time.java b/org/fenix/llanfair/Time.java new file mode 100644 index 0000000..20f6c9a --- /dev/null +++ b/org/fenix/llanfair/Time.java @@ -0,0 +1,298 @@ +package org.fenix.llanfair; + +import java.io.Serializable; +import org.fenix.llanfair.Language; +import org.fenix.llanfair.config.Accuracy; +import static org.fenix.llanfair.config.Accuracy.HUNDREDTH; +import static org.fenix.llanfair.config.Accuracy.SECONDS; +import static org.fenix.llanfair.config.Accuracy.TENTH; +import org.fenix.llanfair.config.Settings; + +/** + * Represents independent time values. Times can be compared between each other + * or to zero using the constant {@link Time.ZERO}. Times can also be displayed + * as a user-friendly string according to a given accuracy. Accuracy is a + * float statically set for every Time objects but that can also be passed as a + * parameter with {@code toString()}. + * + * There exists two ways of representing a time: the standard format (H:M:S) or + * the delta format (+/-H:M:S) if the time object represents a delta between two + * times. + * + * @author Xavier "Xunkar" Sencert + * @version 1.1 + */ +public class Time implements Cloneable, Comparable