missing pieces from old fanfix
authorNiki Roo <niki@nikiroo.be>
Tue, 5 May 2020 11:44:15 +0000 (13:44 +0200)
committerNiki Roo <niki@nikiroo.be>
Tue, 5 May 2020 11:44:15 +0000 (13:44 +0200)
src/be/nikiroo/fanfix_jexer/Main.java
src/be/nikiroo/fanfix_jexer/reader/ConfigItem.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/ConfigItemString.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TOptionWindow.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TSimpleScrollableWindow.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TuiReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TuiReaderApplication.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TuiReaderMainWindow.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TuiReaderOptionWindow.java [new file with mode: 0644]
src/be/nikiroo/fanfix_jexer/reader/TuiReaderStoryWindow.java [new file with mode: 0644]

index eba2d2e4b685d431be690b2b9f1d797918185a53..f5c53a32ffb30e08d8d21a182a6ad7b68e2f18d3 100644 (file)
@@ -4,7 +4,7 @@ import java.io.IOException;
 
 import be.nikiroo.fanfix.Instance;
 import be.nikiroo.fanfix.reader.Reader.ReaderType;
-import be.nikiroo.fanfix.reader.tui.TuiReader;
+import be.nikiroo.fanfix_jexer.reader.TuiReader;
 
 /**
  * The main class of the application, the launcher.
@@ -34,10 +34,6 @@ public class Main {
                Instance.init();
 
                TuiReader.setDefaultReaderType(ReaderType.TUI);
-               try {
-                       TuiReader.getReader().browse(null);
-               } catch (IOException e) {
-                       Instance.getInstance().getTraceHandler().error(e);
-               }
+               new TuiReader().browse(null);
        }
 }
diff --git a/src/be/nikiroo/fanfix_jexer/reader/ConfigItem.java b/src/be/nikiroo/fanfix_jexer/reader/ConfigItem.java
new file mode 100644 (file)
index 0000000..d1bd93a
--- /dev/null
@@ -0,0 +1,362 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import java.util.List;
+
+import jexer.TAction;
+import jexer.TButton;
+import jexer.TLabel;
+import jexer.TPanel;
+import jexer.TWidget;
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.MetaInfo;
+import be.nikiroo.utils.ui.ConfigItemBase;
+
+/**
+ * A graphical item that reflect a configuration option from the given
+ * {@link Bundle}.
+ * <p>
+ * This graphical item can be edited, and the result will be saved back into the
+ * linked {@link MetaInfo}; you still have to save the {@link MetaInfo} should
+ * you wish to, of course.
+ * 
+ * @author niki
+ * 
+ * @param <E>
+ *            the type of {@link Bundle} to edit
+ */
+public abstract class ConfigItem<E extends Enum<E>> extends TWidget {
+       /** The code base */
+       private final ConfigItemBase<TWidget, E> base;
+
+       /**
+        * Prepare a new {@link ConfigItem} instance, linked to the given
+        * {@link MetaInfo}.
+        * 
+        * @param parent
+        *            the parent widget
+        * @param info
+        *            the info
+        * @param autoDirtyHandling
+        *            TRUE to automatically manage the setDirty/Save operations,
+        *            FALSE if you want to do it yourself via
+        *            {@link ConfigItem#setDirtyItem(int)}
+        */
+       protected ConfigItem(TWidget parent, MetaInfo<E> info,
+                       boolean autoDirtyHandling) {
+               super(parent);
+
+               base = new ConfigItemBase<TWidget, E>(info, autoDirtyHandling) {
+                       @Override
+                       protected TWidget createEmptyField(int item) {
+                               return ConfigItem.this.createEmptyField(item);
+                       }
+
+                       @Override
+                       protected Object getFromInfo(int item) {
+                               return ConfigItem.this.getFromInfo(item);
+                       }
+
+                       @Override
+                       protected void setToInfo(Object value, int item) {
+                               ConfigItem.this.setToInfo(value, item);
+                       }
+
+                       @Override
+                       protected Object getFromField(int item) {
+                               return ConfigItem.this.getFromField(item);
+                       }
+
+                       @Override
+                       protected void setToField(Object value, int item) {
+                               ConfigItem.this.setToField(value, item);
+                       }
+
+                       @Override
+                       public TWidget createField(int item) {
+                               TWidget field = super.createField(item);
+
+                               // TODO: size?
+
+                               return field;
+                       }
+
+                       @Override
+                       public List<TWidget> reload() {
+                               List<TWidget> removed = base.reload();
+                               if (!removed.isEmpty()) {
+                                       for (TWidget c : removed) {
+                                               removeChild(c);
+                                       }
+                               }
+
+                               return removed;
+                       }
+               };
+       }
+
+       /**
+        * Create a new {@link ConfigItem} for the given {@link MetaInfo}.
+        * 
+        * @param nhgap
+        *            negative horisontal gap in pixel to use for the label, i.e.,
+        *            the step lock sized labels will start smaller by that amount
+        *            (the use case would be to align controls that start at a
+        *            different horisontal position)
+        */
+       public void init(int nhgap) {
+               if (getInfo().isArray()) {
+                       // TODO: width
+                       int size = getInfo().getListSize(false);
+                       final TPanel pane = new TPanel(this, 0, 0, 20, size + 2);
+                       final TWidget label = label(0, 0, nhgap);
+                       label.setParent(pane, false);
+                       setHeight(pane.getHeight());
+
+                       for (int i = 0; i < size; i++) {
+                               // TODO: minusPanel
+                               TWidget field = base.addItem(i, null);
+                               field.setParent(pane, false);
+                               field.setX(label.getWidth() + 1);
+                               field.setY(i);
+                       }
+
+                       // x, y
+                       final TButton add = new TButton(pane, "+", label.getWidth() + 1,
+                                       size + 1, null);
+                       TAction action = new TAction() {
+                               @Override
+                               public void DO() {
+                                       TWidget field = base.addItem(base.getFieldsSize(), null);
+                                       field.setParent(pane, false);
+                                       field.setX(label.getWidth() + 1);
+                                       field.setY(add.getY());
+                                       add.setY(add.getY() + 1);
+                               }
+                       };
+                       add.setAction(action);
+               } else {
+                       final TWidget label = label(0, 0, nhgap);
+
+                       TWidget field = base.createField(-1);
+                       field.setX(label.getWidth() + 1);
+                       field.setWidth(10); // TODO
+
+                       // TODO
+                       setWidth(30);
+                       setHeight(1);
+               }
+       }
+
+       /** The {@link MetaInfo} linked to the field. */
+       public MetaInfo<E> getInfo() {
+               return base.getInfo();
+       }
+
+       /**
+        * Retrieve the associated graphical component that was created with
+        * {@link ConfigItemBase#createEmptyField(int)}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the graphical component
+        */
+       protected TWidget getField(int item) {
+               return base.getField(item);
+       }
+
+       /**
+        * Manually specify that the given item is "dirty" and thus should be saved
+        * when asked.
+        * <p>
+        * Has no effect if the class is using automatic dirty handling (see
+        * {@link ConfigItemBase#ConfigItem(MetaInfo, boolean)}).
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       protected void setDirtyItem(int item) {
+               base.setDirtyItem(item);
+       }
+
+       /**
+        * Check if the value changed since the last load/save into the linked
+        * {@link MetaInfo}.
+        * <p>
+        * Note that we consider NULL and an Empty {@link String} to be equals.
+        * 
+        * @param value
+        *            the value to test
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return TRUE if it has
+        */
+       protected boolean hasValueChanged(Object value, int item) {
+               return base.hasValueChanged(value, item);
+       }
+
+       /**
+        * Create an empty graphical component to be used later by
+        * {@link ConfigItem#createField(int)}.
+        * <p>
+        * Note that {@link ConfigItem#reload(int)} will be called after it was
+        * created by {@link ConfigItem#createField(int)}.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the graphical component
+        */
+       abstract protected TWidget createEmptyField(int item);
+
+       /**
+        * Get the information from the {@link MetaInfo} in the subclass preferred
+        * format.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the information in the subclass preferred format
+        */
+       abstract protected Object getFromInfo(int item);
+
+       /**
+        * Set the value to the {@link MetaInfo}.
+        * 
+        * @param value
+        *            the value in the subclass preferred format
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       abstract protected void setToInfo(Object value, int item);
+
+       /**
+        * The value present in the given item's related field in the subclass
+        * preferred format.
+        * 
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        * 
+        * @return the value present in the given item's related field in the
+        *         subclass preferred format
+        */
+       abstract protected Object getFromField(int item);
+
+       /**
+        * Set the value (in the subclass preferred format) into the field.
+        * 
+        * @param value
+        *            the value in the subclass preferred format
+        * @param item
+        *            the item number to get for an array of values, or -1 to get
+        *            the whole value (has no effect if {@link MetaInfo#isArray()}
+        *            is FALSE)
+        */
+       abstract protected void setToField(Object value, int item);
+
+       /**
+        * Create a label which width is constrained in lock steps.
+        * 
+        * @param x
+        *            the X position of the label
+        * @param y
+        *            the Y position of the label
+        * @param nhgap
+        *            negative horisontal gap in pixel to use for the label, i.e.,
+        *            the step lock sized labels will start smaller by that amount
+        *            (the use case would be to align controls that start at a
+        *            different horisontal position)
+        * 
+        * @return the label
+        */
+       protected TWidget label(int x, int y, int nhgap) {
+               // TODO: see Swing version for lock-step sizes
+               // TODO: see Swing version for help info-buttons
+
+               String lbl = getInfo().getName();
+               return new TLabel(this, lbl, x, y);
+       }
+
+       /**
+        * Create a new {@link ConfigItem} for the given {@link MetaInfo}.
+        * 
+        * @param <E>
+        *            the type of {@link Bundle} to edit
+        * 
+        * @param x
+        *            the X position of the item
+        * @param y
+        *            the Y position of the item
+        * @param parent
+        *            the parent widget to use for this one
+        * @param info
+        *            the {@link MetaInfo}
+        * @param nhgap
+        *            negative horisontal gap in pixel to use for the label, i.e.,
+        *            the step lock sized labels will start smaller by that amount
+        *            (the use case would be to align controls that start at a
+        *            different horisontal position)
+        * 
+        * @return the new {@link ConfigItem}
+        */
+       static public <E extends Enum<E>> ConfigItem<E> createItem(TWidget parent,
+                       int x, int y, MetaInfo<E> info, int nhgap) {
+
+               ConfigItem<E> configItem;
+               switch (info.getFormat()) {
+               // TODO
+               // case BOOLEAN:
+               // configItem = new ConfigItemBoolean<E>(info);
+               // break;
+               // case COLOR:
+               // configItem = new ConfigItemColor<E>(info);
+               // break;
+               // case FILE:
+               // configItem = new ConfigItemBrowse<E>(info, false);
+               // break;
+               // case DIRECTORY:
+               // configItem = new ConfigItemBrowse<E>(info, true);
+               // break;
+               // case COMBO_LIST:
+               // configItem = new ConfigItemCombobox<E>(info, true);
+               // break;
+               // case FIXED_LIST:
+               // configItem = new ConfigItemCombobox<E>(info, false);
+               // break;
+               // case INT:
+               // configItem = new ConfigItemInteger<E>(info);
+               // break;
+               // case PASSWORD:
+               // configItem = new ConfigItemPassword<E>(info);
+               // break;
+               // case LOCALE:
+               // configItem = new ConfigItemLocale<E>(info);
+               // break;
+               // case STRING:
+               default:
+                       configItem = new ConfigItemString<E>(parent, info);
+                       break;
+               }
+
+               configItem.init(nhgap);
+               configItem.setX(x);
+               configItem.setY(y);
+
+               return configItem;
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/ConfigItemString.java b/src/be/nikiroo/fanfix_jexer/reader/ConfigItemString.java
new file mode 100644 (file)
index 0000000..8403fef
--- /dev/null
@@ -0,0 +1,50 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import jexer.TField;
+import jexer.TWidget;
+import be.nikiroo.utils.resources.MetaInfo;
+
+class ConfigItemString<E extends Enum<E>> extends ConfigItem<E> {
+       /**
+        * Create a new {@link ConfigItemString} for the given {@link MetaInfo}.
+        * 
+        * @param info
+        *            the {@link MetaInfo}
+        */
+       public ConfigItemString(TWidget parent, MetaInfo<E> info) {
+               super(parent, info, true);
+       }
+
+       @Override
+       protected Object getFromField(int item) {
+               TField field = (TField) getField(item);
+               if (field != null) {
+                       return field.getText();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected Object getFromInfo(int item) {
+               return getInfo().getString(item, false);
+       }
+
+       @Override
+       protected void setToField(Object value, int item) {
+               TField field = (TField) getField(item);
+               if (field != null) {
+                       field.setText(value == null ? "" : value.toString());
+               }
+       }
+
+       @Override
+       protected void setToInfo(Object value, int item) {
+               getInfo().setString((String) value, item);
+       }
+
+       @Override
+       protected TWidget createEmptyField(int item) {
+               return new TField(this, 0, 0, 1, false);
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TOptionWindow.java b/src/be/nikiroo/fanfix_jexer/reader/TOptionWindow.java
new file mode 100644 (file)
index 0000000..510d694
--- /dev/null
@@ -0,0 +1,120 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TPanel;
+import jexer.TWidget;
+import jexer.event.TCommandEvent;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.MetaInfo;
+
+public class TOptionWindow<E extends Enum<E>> extends TSimpleScrollableWindow {
+       private List<MetaInfo<E>> items;
+
+       public TOptionWindow(TApplication parent, Class<E> type,
+                       final Bundle<E> bundle, String title) {
+               super(parent, title, 0, 0, CENTERED | RESIZABLE);
+
+               getMainPane().addLabel(title, 0, 0);
+
+               items = new ArrayList<MetaInfo<E>>();
+               List<MetaInfo<E>> groupedItems = MetaInfo.getItems(type, bundle);
+               int y = 2;
+               for (MetaInfo<E> item : groupedItems) {
+                       // will populate this.items
+                       y += addItem(getMainPane(), 5, y, item, 0).getHeight();
+               }
+
+               y++;
+
+               setRealHeight(y + 1);
+
+               getMainPane().addButton("Reset", 25, y, new TAction() {
+                       @Override
+                       public void DO() {
+                               for (MetaInfo<E> item : items) {
+                                       item.reload();
+                               }
+                       }
+               });
+
+               getMainPane().addButton("Default", 15, y, new TAction() {
+                       @Override
+                       public void DO() {
+                               Object snap = bundle.takeSnapshot();
+                               bundle.reload(true);
+                               for (MetaInfo<E> item : items) {
+                                       item.reload();
+                               }
+                               bundle.reload(false);
+                               bundle.restoreSnapshot(snap);
+                       }
+               });
+
+               getMainPane().addButton("Save", 1, y, new TAction() {
+                       @Override
+                       public void DO() {
+                               for (MetaInfo<E> item : items) {
+                                       item.save(true);
+                               }
+
+                               try {
+                                       bundle.updateFile();
+                               } catch (IOException e1) {
+                                       e1.printStackTrace();
+                               }
+                       }
+               });
+       }
+
+       private TWidget addItem(TWidget parent, int x, int y, MetaInfo<E> item,
+                       int nhgap) {
+               if (item.isGroup()) {
+                       // TODO: width
+                       int w = 80 - x;
+
+                       String name = item.getName();
+                       String info = item.getDescription();
+                       info = StringUtils.justifyTexts(info, w - 3); // -3 for borders
+
+                       final TPanel pane = new TPanel(parent, x, y, w, 1);
+                       pane.addLabel(name, 0, 0);
+
+                       int h = 0;
+                       if (!info.isEmpty()) {
+                               h += info.split("\n").length + 1; // +1 for scroll
+                               pane.addText(info + "\n", 0, 1, w, h);
+                       }
+
+                       // +1 for the title
+                       h++;
+
+                       int paneY = h; // for the info desc
+                       for (MetaInfo<E> subitem : item) {
+                               paneY += addItem(pane, 4, paneY, subitem, nhgap + 11)
+                                               .getHeight();
+                       }
+
+                       pane.setHeight(paneY);
+                       return pane;
+               }
+
+               items.add(item);
+               return ConfigItem.createItem(parent, x, y, item, nhgap);
+       }
+
+       @Override
+       public void onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderApplication.CMD_EXIT)) {
+                       TuiReaderApplication.close(this);
+               } else {
+                       // Handle our own event if needed here
+                       super.onCommand(command);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TSimpleScrollableWindow.java b/src/be/nikiroo/fanfix_jexer/reader/TSimpleScrollableWindow.java
new file mode 100644 (file)
index 0000000..e795656
--- /dev/null
@@ -0,0 +1,150 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import jexer.TApplication;
+import jexer.THScroller;
+import jexer.TPanel;
+import jexer.TScrollableWindow;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+public class TSimpleScrollableWindow extends TScrollableWindow {
+       protected TPanel mainPane;
+       private int prevHorizontal = -1;
+       private int prevVertical = -1;
+
+       public TSimpleScrollableWindow(TApplication application, String title,
+                       int width, int height) {
+               this(application, title, width, height, 0, 0, 0);
+       }
+
+       public TSimpleScrollableWindow(TApplication application, String title,
+                       int width, int height, int flags) {
+               this(application, title, width, height, flags, 0, 0);
+       }
+
+       // 0 = none (so, no scrollbar)
+       public TSimpleScrollableWindow(TApplication application, String title,
+                       int width, int height, int flags, int realWidth, int realHeight) {
+               super(application, title, width, height, flags);
+
+               mainPane = new TPanel(this, 0, 0, 1, 1) {
+                       @Override
+                       public void draw() {
+                               for (TWidget children : mainPane.getChildren()) {
+                                       int y = children.getY() + children.getHeight();
+                                       int x = children.getX() + children.getWidth();
+                                       boolean visible = (y > getVerticalValue())
+                                                       && (x > getHorizontalValue());
+                                       children.setVisible(visible);
+                               }
+                               super.draw();
+                       }
+               };
+
+               mainPane.setWidth(getWidth());
+               mainPane.setHeight(getHeight());
+
+               setRealWidth(realWidth);
+               setRealHeight(realHeight);
+               placeScrollbars();
+       }
+
+       /**
+        * The main pane on which you can add other widgets for this scrollable
+        * window.
+        * 
+        * @return the main pane
+        */
+       public TPanel getMainPane() {
+               return mainPane;
+       }
+
+       public void setRealWidth(int realWidth) {
+               if (realWidth <= 0) {
+                       if (hScroller != null) {
+                               hScroller.remove();
+                       }
+               } else {
+                       if (hScroller == null) {
+                               // size/position will be fixed by placeScrollbars()
+                               hScroller = new THScroller(this, 0, 0, 10);
+                       }
+                       setRightValue(realWidth);
+               }
+
+               reflowData();
+       }
+
+       public void setRealHeight(int realHeight) {
+               if (realHeight <= 0) {
+                       if (vScroller != null) {
+                               vScroller.remove();
+                       }
+               } else {
+                       if (vScroller == null) {
+                               // size/position will be fixed by placeScrollbars()
+                               vScroller = new TVScroller(this, 0, 0, 10);
+                       }
+                       setBottomValue(realHeight);
+               }
+
+               reflowData();
+       }
+
+       @Override
+       public void onResize(TResizeEvent event) {
+               super.onResize(event);
+               mainPane.setWidth(getWidth());
+               mainPane.setHeight(getHeight());
+               mainPane.onResize(event);
+       }
+
+       @Override
+       public void reflowData() {
+               super.reflowData();
+               reflowData(getHorizontalValue(), getVerticalValue());
+       }
+
+       protected void reflowData(int totalX, int totalY) {
+               super.reflowData();
+               mainPane.setX(-totalX);
+               mainPane.setY(-totalY);
+       }
+
+       @Override
+       public void onMouseUp(TMouseEvent mouse) {
+               super.onMouseUp(mouse);
+
+               // TODO: why? this should already be done by the scrollers
+               // it could also mean we do it twice if, somehow, it sometime works...
+               int mrx = mouse.getX();
+               int mry = mouse.getY();
+
+               int mx = mouse.getAbsoluteX();
+               int my = mouse.getAbsoluteY();
+
+               if (vScroller != null) {
+                       mouse.setX(mx - vScroller.getAbsoluteX());
+                       mouse.setY(my - vScroller.getAbsoluteY());
+                       vScroller.onMouseUp(mouse);
+               }
+               if (hScroller != null) {
+                       mouse.setX(mx - hScroller.getAbsoluteX());
+                       mouse.setY(my - hScroller.getAbsoluteY());
+                       hScroller.onMouseUp(mouse);
+               }
+
+               mouse.setX(mrx);
+               mouse.setY(mry);
+               //
+
+               if (prevHorizontal != getHorizontalValue()
+                               || prevVertical != getVerticalValue()) {
+                       prevHorizontal = getHorizontalValue();
+                       prevVertical = getVerticalValue();
+                       reflowData();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TuiReader.java b/src/be/nikiroo/fanfix_jexer/reader/TuiReader.java
new file mode 100644 (file)
index 0000000..7eb0f25
--- /dev/null
@@ -0,0 +1,107 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import java.io.IOException;
+
+import jexer.TApplication;
+import jexer.TApplication.BackendType;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.reader.BasicReader;
+import be.nikiroo.fanfix.reader.Reader;
+import be.nikiroo.fanfix.supported.SupportType;
+
+/**
+ * This {@link Reader}is based upon the TUI widget library 'jexer'
+ * (https://github.com/klamonte/jexer/) and offer, as its name suggest, a Text
+ * User Interface.
+ * <p>
+ * It is expected to be on par with the GUI version.
+ * 
+ * @author niki
+ */
+public class TuiReader extends BasicReader {
+       /**
+        * Will detect the backend to use.
+        * <p>
+        * Swing is the default backend on Windows and MacOS while evreything else
+        * will use XTERM unless explicitly overridden by <tt>jexer.Swing</tt> =
+        * <tt>true</tt> or <tt>false</tt>.
+        * 
+        * @return the backend to use
+        */
+       private static BackendType guessBackendType() {
+               // TODO: allow a config option to force one or the other?
+               TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+               if (System.getProperty("os.name").startsWith("Windows")) {
+                       backendType = TApplication.BackendType.SWING;
+               }
+
+               if (System.getProperty("os.name").startsWith("Mac")) {
+                       backendType = TApplication.BackendType.SWING;
+               }
+
+               if (System.getProperty("jexer.Swing") != null) {
+                       if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                               backendType = TApplication.BackendType.SWING;
+                       } else {
+                               backendType = TApplication.BackendType.XTERM;
+                       }
+               }
+
+               return backendType;
+       }
+
+       @Override
+       public void read(boolean sync) throws IOException {
+               // TODO
+               if (!sync) {
+                       // How could you do a not-sync in TUI mode?
+                       throw new java.lang.IllegalStateException(
+                                       "Async mode not implemented yet.");
+               }
+
+               try {
+                       TuiReaderApplication app = new TuiReaderApplication(this,
+                                       guessBackendType());
+                       app.run();
+               } catch (Exception e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public void browse(String source) {
+               try {
+                       TuiReaderApplication app = new TuiReaderApplication(this, source,
+                                       guessBackendType());
+                       app.run();
+               } catch (Exception e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public void search(boolean sync) throws IOException {
+               // TODO
+               if (sync) {
+                       throw new java.lang.IllegalStateException("Not implemented yet.");
+               }
+       }
+
+       @Override
+       public void search(SupportType searchOn, String keywords, int page,
+                       int item, boolean sync) {
+               // TODO
+               if (sync) {
+                       throw new java.lang.IllegalStateException("Not implemented yet.");
+               }
+       }
+
+       @Override
+       public void searchTag(SupportType searchOn, int page, int item,
+                       boolean sync, Integer... tags) {
+               // TODO
+               if (sync) {
+                       throw new java.lang.IllegalStateException("Not implemented yet.");
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TuiReaderApplication.java b/src/be/nikiroo/fanfix_jexer/reader/TuiReaderApplication.java
new file mode 100644 (file)
index 0000000..f621309
--- /dev/null
@@ -0,0 +1,467 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import java.awt.Toolkit;
+import java.awt.datatransfer.DataFlavor;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+
+import jexer.TApplication;
+import jexer.TCommand;
+import jexer.TKeypress;
+import jexer.TMessageBox;
+import jexer.TMessageBox.Result;
+import jexer.TMessageBox.Type;
+import jexer.TStatusBar;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TMenuEvent;
+import jexer.menu.TMenu;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.reader.BasicReader;
+import be.nikiroo.fanfix.reader.Reader;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.fanfix_jexer.reader.TuiReaderMainWindow.Mode;
+import be.nikiroo.utils.Progress;
+
+/**
+ * Manages the TUI general mode and links and manages the {@link TWindow}s.
+ * <p>
+ * It will also enclose a {@link Reader} and simply handle the reading part
+ * differently (it will create the required sub-windows and display them).
+ * 
+ * @author niki
+ */
+class TuiReaderApplication extends TApplication implements Reader {
+       public static final int MENU_FILE_OPEN = 1025;
+       public static final int MENU_FILE_IMPORT_URL = 1026;
+       public static final int MENU_FILE_IMPORT_FILE = 1027;
+       public static final int MENU_FILE_EXPORT = 1028;
+       public static final int MENU_FILE_DELETE = 1029;
+       public static final int MENU_FILE_LIBRARY = 1030;
+       public static final int MENU_FILE_EXIT = 1031;
+       //
+       public static final int MENU_OPT_FANFIX = 1032;
+       public static final int MENU_OPT_TUI = 1033;
+       
+
+       public static final TCommand CMD_EXIT = new TCommand(MENU_FILE_EXIT) {
+       };
+
+       private Reader reader;
+       private TuiReaderMainWindow main;
+
+       // start reading if meta present
+       public TuiReaderApplication(Reader reader, BackendType backend)
+                       throws Exception {
+               super(backend);
+               init(reader);
+
+               if (getMeta() != null) {
+                       read(false);
+               }
+       }
+
+       public TuiReaderApplication(Reader reader, String source,
+                       TApplication.BackendType backend) throws Exception {
+               super(backend);
+               init(reader);
+               
+               showMain();
+               main.setMode(Mode.SOURCE, source);
+       }
+
+       @Override
+       public void read(boolean sync) throws IOException {
+               read(getStory(null), sync);
+       }
+
+       @Override
+       public MetaData getMeta() {
+               return reader.getMeta();
+       }
+
+       @Override
+       public Story getStory(Progress pg) throws IOException {
+               return reader.getStory(pg);
+       }
+
+       @Override
+       public BasicLibrary getLibrary() {
+               return reader.getLibrary();
+       }
+
+       @Override
+       public void setLibrary(BasicLibrary lib) {
+               reader.setLibrary(lib);
+       }
+
+       @Override
+       public void setMeta(MetaData meta) throws IOException {
+               reader.setMeta(meta);
+       }
+
+       @Override
+       public void setMeta(String luid) throws IOException {
+               reader.setMeta(luid);
+       }
+
+       @Override
+       public void setMeta(URL source, Progress pg) throws IOException {
+               reader.setMeta(source, pg);
+       }
+
+       @Override
+       public void browse(String source) {
+               try {
+                       reader.browse(source);
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public int getChapter() {
+               return reader.getChapter();
+       }
+
+       @Override
+       public void setChapter(int chapter) {
+               reader.setChapter(chapter);
+       }
+
+       @Override
+       public void search(boolean sync) throws IOException {
+               reader.search(sync);
+       }
+
+       @Override
+       public void search(SupportType searchOn, String keywords, int page,
+                       int item, boolean sync) throws IOException {
+               reader.search(searchOn, keywords, page, item, sync);
+       }
+
+       @Override
+       public void searchTag(SupportType searchOn, int page, int item,
+                       boolean sync, Integer... tags) throws IOException {
+               reader.searchTag(searchOn, page, item, sync, tags);
+       }
+
+       /**
+        * Open the given {@link Story} for reading. This may or may not start an
+        * external program to read said {@link Story}.
+        * 
+        * @param story
+        *            the {@link Story} to read
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       public void read(Story story, boolean sync) throws IOException {
+               if (story == null) {
+                       throw new IOException("No story to read");
+               }
+
+               // TODO: open in editor + external option
+               if (!story.getMeta().isImageDocument()) {
+                       TWindow window = new TuiReaderStoryWindow(this, story, getChapter());
+                       window.maximize();
+               } else {
+                       try {
+                               openExternal(getLibrary(), story.getMeta().getLuid(), sync);
+                       } catch (IOException e) {
+                               messageBox("Error when trying to open the story",
+                                               e.getMessage(), TMessageBox.Type.OK);
+                       }
+               }
+       }
+
+       /**
+        * Set the default status bar when this window appear.
+        * <p>
+        * Some shortcuts are always visible, and will be put here.
+        * <p>
+        * Note that shortcuts placed this way on menu won't work unless the menu
+        * also implement them.
+        * 
+        * @param window
+        *            the new window or menu on screen
+        * @param description
+        *            the description to show on the status ba
+        */
+       public TStatusBar setStatusBar(TWindow window, String description) {
+               TStatusBar statusBar = window.newStatusBar(description);
+               statusBar.addShortcutKeypress(TKeypress.kbF10, CMD_EXIT, "Exit");
+               return statusBar;
+
+       }
+
+       private void showMain() {
+               if (main != null && main.isVisible()) {
+                       main.activate();
+               } else {
+                       if (main != null) {
+                               main.close();
+                       }
+                       main = new TuiReaderMainWindow(this);
+                       main.maximize();
+               }
+       }
+
+       private void init(Reader reader) {
+               this.reader = reader;
+
+               // TODO: traces/errors?
+               Instance.getInstance().setTraceHandler(null);
+
+               // Add the menus TODO: i18n
+               TMenu fileMenu = addMenu("&File");
+               fileMenu.addItem(MENU_FILE_OPEN, "&Open...");
+               fileMenu.addItem(MENU_FILE_EXPORT, "&Save as...");
+               fileMenu.addItem(MENU_FILE_DELETE, "&Delete...");
+               // TODO: Move to...
+               fileMenu.addSeparator();
+               fileMenu.addItem(MENU_FILE_IMPORT_URL, "Import &URL...");
+               fileMenu.addItem(MENU_FILE_IMPORT_FILE, "Import &file...");
+               fileMenu.addSeparator();
+               fileMenu.addItem(MENU_FILE_LIBRARY, "Lib&rary");
+               fileMenu.addSeparator();
+               fileMenu.addItem(MENU_FILE_EXIT, "E&xit");
+               
+               TMenu OptionsMenu = addMenu("&Options");
+               OptionsMenu.addItem(MENU_OPT_FANFIX, "&Fanfix Configuration");
+               OptionsMenu.addItem(MENU_OPT_TUI, "&UI Configuration");
+
+               setStatusBar(fileMenu, "File-management "
+                               + "commands (Open, Save, Print, etc.)");
+               
+
+               // TODO: Edit: re-download, delete
+
+               //
+
+               addWindowMenu();
+
+               getBackend().setTitle("Fanfix");
+       }
+
+       @Override
+       protected boolean onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderMainWindow.CMD_SEARCH)) {
+                       messageBox("title", "caption");
+                       return true;
+               }
+               return super.onCommand(command);
+       }
+
+       @Override
+       protected boolean onMenu(TMenuEvent menu) {
+               // TODO: i18n
+               switch (menu.getId()) {
+               case MENU_FILE_EXIT:
+                       close(this);
+                       return true;
+               case MENU_FILE_OPEN:
+                       String openfile = null;
+                       try {
+                               openfile = fileOpenBox(".");
+                               reader.setMeta(BasicReader.getUrl(openfile), null);
+                               read(false);
+                       } catch (IOException e) {
+                               // TODO: i18n
+                               error("Fail to open file"
+                                               + (openfile == null ? "" : ": " + openfile),
+                                               "Import error", e);
+                       }
+
+                       return true;
+               case MENU_FILE_DELETE:
+                       String luid = null;
+                       String story = null;
+                       MetaData meta = null;
+                       if (main != null) {
+                               meta = main.getSelectedMeta();
+                       }
+                       if (meta != null) {
+                               luid = meta.getLuid();
+                               story = luid + ": " + meta.getTitle();
+                       }
+
+                       // TODO: i18n
+                       TMessageBox mbox = messageBox("Delete story", "Delete story \""
+                                       + story + "\"", Type.OKCANCEL);
+                       if (mbox.getResult() == Result.OK) {
+                               try {
+                                       reader.getLibrary().delete(luid);
+                                       if (main != null) {
+                                               main.refreshStories();
+                                       }
+                               } catch (IOException e) {
+                                       // TODO: i18n
+                                       error("Fail to delete the story: \"" + story + "\"",
+                                                       "Error", e);
+                               }
+                       }
+
+                       return true;
+               case MENU_FILE_IMPORT_URL:
+                       String clipboard = "";
+                       try {
+                               clipboard = ("" + Toolkit.getDefaultToolkit()
+                                               .getSystemClipboard().getData(DataFlavor.stringFlavor))
+                                               .trim();
+                       } catch (Exception e) {
+                               // No data will be handled
+                       }
+
+                       if (clipboard == null || !clipboard.startsWith("http")) {
+                               clipboard = "";
+                       }
+
+                       String url = inputBox("Import story", "URL to import", clipboard)
+                                       .getText();
+
+                       try {
+                               if (!imprt(url)) {
+                                       // TODO: i18n
+                                       error("URK not supported: " + url, "Import error");
+                               }
+                       } catch (IOException e) {
+                               // TODO: i18n
+                               error("Fail to import URL: " + url, "Import error", e);
+                       }
+
+                       return true;
+               case MENU_FILE_IMPORT_FILE:
+                       String filename = null;
+                       try {
+                               filename = fileOpenBox(".");
+                               if (!imprt(filename)) {
+                                       // TODO: i18n
+                                       error("File not supported: " + filename, "Import error");
+                               }
+                       } catch (IOException e) {
+                               // TODO: i18n
+                               error("Fail to import file"
+                                               + (filename == null ? "" : ": " + filename),
+                                               "Import error", e);
+                       }
+                       return true;
+               case MENU_FILE_LIBRARY:
+                       showMain();
+                       return true;
+                       
+               case MENU_OPT_FANFIX:
+                       new TuiReaderOptionWindow(this, false).maximize();
+                       return true;
+               
+               case MENU_OPT_TUI:
+                       new TuiReaderOptionWindow(this, true).maximize();
+                       return true;
+                       
+               }
+
+               return super.onMenu(menu);
+       }
+
+       /**
+        * Import the given url.
+        * <p>
+        * Can fail if the host is not supported.
+        * 
+        * @param url
+        * 
+        * @return TRUE in case of success, FALSE if the host is not supported
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private boolean imprt(String url) throws IOException {
+               try {
+                       reader.getLibrary().imprt(BasicReader.getUrl(url), null);
+                       main.refreshStories();
+                       return true;
+               } catch (UnknownHostException e) {
+                       return false;
+               }
+       }
+
+       @Override
+       public void openExternal(BasicLibrary lib, String luid, boolean sync)
+                       throws IOException {
+               reader.openExternal(lib, luid, sync);
+       }
+
+       /**
+        * Display an error message and log it.
+        * 
+        * @param message
+        *            the message
+        * @param title
+        *            the title of the error message
+        */
+       private void error(String message, String title) {
+               error(message, title, null);
+       }
+
+       /**
+        * Display an error message and log it, including the linked
+        * {@link Exception}.
+        * 
+        * @param message
+        *            the message
+        * @param title
+        *            the title of the error message
+        * @param e
+        *            the exception to log if any (can be NULL)
+        */
+       private void error(String message, String title, Exception e) {
+               Instance.getInstance().getTraceHandler().error(title + ": " + message);
+               if (e != null) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+
+               if (e != null) {
+                       messageBox(title, message //
+                                       + "\n" + e.getMessage());
+               } else {
+                       messageBox(title, message);
+               }
+       }
+
+       /**
+        * Ask the user and, if confirmed, close the {@link TApplication} this
+        * {@link TWidget} is running on.
+        * <p>
+        * This should result in the program terminating.
+        * 
+        * @param widget
+        *            the {@link TWidget}
+        */
+       static public void close(TWidget widget) {
+               close(widget.getApplication());
+       }
+
+       /**
+        * Ask the user and, if confirmed, close the {@link TApplication}.
+        * <p>
+        * This should result in the program terminating.
+        * 
+        * @param app
+        *            the {@link TApplication}
+        */
+       static void close(TApplication app) {
+               // TODO: i18n
+               if (app.messageBox("Confirmation", "(TODO: i18n) Exit application?",
+                               TMessageBox.Type.YESNO).getResult() == TMessageBox.Result.YES) {
+                       app.exit();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TuiReaderMainWindow.java b/src/be/nikiroo/fanfix_jexer/reader/TuiReaderMainWindow.java
new file mode 100644 (file)
index 0000000..2429583
--- /dev/null
@@ -0,0 +1,374 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.TAction;
+import jexer.TComboBox;
+import jexer.TCommand;
+import jexer.TField;
+import jexer.TFileOpenBox.Type;
+import jexer.TKeypress;
+import jexer.TLabel;
+import jexer.TList;
+import jexer.TStatusBar;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TResizeEvent;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.jexer.TSizeConstraint;
+
+/**
+ * The library window, that will list all the (filtered) stories available in
+ * this {@link BasicLibrary}.
+ * 
+ * @author niki
+ */
+class TuiReaderMainWindow extends TWindow {
+       public static final int MENU_SEARCH = 1100;
+       public static final TCommand CMD_SEARCH = new TCommand(MENU_SEARCH) {
+       };
+
+       public enum Mode {
+               SOURCE, AUTHOR,
+       }
+
+       private TList list;
+       private List<MetaData> listKeys;
+       private List<String> listItems;
+       private TuiReaderApplication reader;
+
+       private Mode mode = Mode.SOURCE;
+       private String target = null;
+       private String filter = "";
+
+       private List<TSizeConstraint> sizeConstraints = new ArrayList<TSizeConstraint>();
+
+       // The 2 comboboxes used to select by source/author
+       private TComboBox selectTargetBox;
+       private TComboBox selectBox;
+
+       /**
+        * Create a new {@link TuiReaderMainWindow} without any stories in the list.
+        * 
+        * @param reader
+        *            the reader and main application
+        */
+       public TuiReaderMainWindow(TuiReaderApplication reader) {
+               // Construct a demo window. X and Y don't matter because it will be
+               // centred on screen.
+               super(reader, "Library", 0, 0, 60, 18, CENTERED | RESIZABLE);
+
+               this.reader = reader;
+
+               listKeys = new ArrayList<MetaData>();
+               listItems = new ArrayList<String>();
+
+               addList();
+               addSearch();
+               addSelect();
+
+               TStatusBar statusBar = reader.setStatusBar(this, "Library");
+               statusBar.addShortcutKeypress(TKeypress.kbCtrlF, CMD_SEARCH, "Search");
+
+               TSizeConstraint.resize(sizeConstraints);
+
+               // TODO: remove when not used anymore
+
+               // addLabel("Label (1,1)", 1, 1);
+               // addButton("&Button (35,1)", 35, 1, new TAction() {
+               // public void DO() {
+               // }
+               // });
+               // addCheckbox(1, 2, "Checky (1,2)", false);
+               // addProgressBar(1, 3, 30, 42);
+               // TRadioGroup groupy = addRadioGroup(1, 4, "Radio groupy");
+               // groupy.addRadioButton("Fanfan");
+               // groupy.addRadioButton("Tulipe");
+               // addField(1, 10, 20, false, "text not fixed.");
+               // addField(1, 11, 20, true, "text fixed.");
+               // addText("20x4 Text in (12,20)", 1, 12, 20, 4);
+               //
+               // TTreeView tree = addTreeView(30, 5, 20, 5);
+               // TTreeItem root = new TTreeItem(tree, "expended root", true);
+               // tree.setSelected(root); // needed to allow arrow navigation without
+               // // mouse-clicking before
+               //
+               // root.addChild("child");
+               // root.addChild("child 2").addChild("sub child");
+       }
+
+       private void addSearch() {
+               TLabel lblSearch = addLabel("Search: ", 0, 0);
+
+               TField search = new TField(this, 0, 0, 1, true) {
+                       @Override
+                       public void onKeypress(TKeypressEvent keypress) {
+                               super.onKeypress(keypress);
+                               TKeypress key = keypress.getKey();
+                               if (key.isFnKey() && key.getKeyCode() == TKeypress.ENTER) {
+                                       TuiReaderMainWindow.this.filter = getText();
+                                       TuiReaderMainWindow.this.refreshStories();
+                               }
+                       }
+               };
+
+               TSizeConstraint.setSize(sizeConstraints, lblSearch, 5, 1, null, null);
+               TSizeConstraint.setSize(sizeConstraints, search, 15, 1, -5, null);
+       }
+
+       private void addList() {
+               list = addList(listItems, 0, 0, 10, 10, new TAction() {
+                       @Override
+                       public void DO() {
+                               MetaData meta = getSelectedMeta();
+                               if (meta != null) {
+                                       readStory(meta);
+                               }
+                       }
+               });
+
+               TSizeConstraint.setSize(sizeConstraints, list, 0, 7, 0, 0);
+       }
+
+       private void addSelect() {
+               // TODO: i18n
+               final List<String> selects = new ArrayList<String>();
+               selects.add("(show all)");
+               selects.add("Sources");
+               selects.add("Author");
+
+               final List<String> selectTargets = new ArrayList<String>();
+               selectTargets.add("");
+
+               TLabel lblSelect = addLabel("Select: ", 0, 0);
+
+               TAction onSelect = new TAction() {
+                       @Override
+                       public void DO() {
+                               String smode = selectBox.getText();
+                               boolean showTarget;
+                               if (smode == null || smode.equals("(show all)")) {
+                                       showTarget = false;
+                               } else if (smode.equals("Sources")) {
+                                       selectTargets.clear();
+                                       selectTargets.add("(show all)");
+                                       try {
+                                               for (String source : reader.getLibrary().getSources()) {
+                                                       selectTargets.add(source);
+                                               }
+                                       } catch (IOException e) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                       }
+
+                                       showTarget = true;
+                               } else {
+                                       selectTargets.clear();
+                                       selectTargets.add("(show all)");
+                                       try {
+                                               for (String author : reader.getLibrary().getAuthors()) {
+                                                       selectTargets.add(author);
+                                               }
+                                       } catch (IOException e) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                       }
+
+                                       showTarget = true;
+                               }
+
+                               selectTargetBox.setVisible(showTarget);
+                               selectTargetBox.setEnabled(showTarget);
+                               if (showTarget) {
+                                       selectTargetBox.reflowData();
+                               }
+
+                               selectTargetBox.setText(selectTargets.get(0));
+                               if (showTarget) {
+                                       TuiReaderMainWindow.this.activate(selectTargetBox);
+                               } else {
+                                       TuiReaderMainWindow.this.activate(list);
+                               }
+                       }
+               };
+
+               selectBox = addComboBox(0, 0, 10, selects, 0, -1, onSelect);
+
+               selectTargetBox = addComboBox(0, 0, 0, selectTargets, 0, -1,
+                               new TAction() {
+                                       @Override
+                                       public void DO() {
+                                               if (selectTargetBox.getText().equals(
+                                                               selectTargets.get(0))) {
+                                                       setMode(mode, null);
+                                               } else {
+                                                       setMode(mode, selectTargetBox.getText());
+                                               }
+                                       }
+                               });
+
+               // Set defaults
+               onSelect.DO();
+
+               TSizeConstraint.setSize(sizeConstraints, lblSelect, 5, 3, null, null);
+               TSizeConstraint.setSize(sizeConstraints, selectBox, 15, 3, -5, null);
+               TSizeConstraint.setSize(sizeConstraints, selectTargetBox, 15, 4, -5,
+                               null);
+       }
+
+       @Override
+       public void onResize(TResizeEvent resize) {
+               super.onResize(resize);
+               TSizeConstraint.resize(sizeConstraints);
+       }
+
+       @Override
+       public void onClose() {
+               setVisible(false);
+               super.onClose();
+       }
+
+       /**
+        * Refresh the list of stories displayed in this library.
+        * <p>
+        * Will take the current settings into account (filter, source...).
+        */
+       public void refreshStories() {
+               List<MetaData> metas;
+
+               try {
+                       if (mode == Mode.SOURCE) {
+                               metas = reader.getLibrary().getList().filter(target, null, null);
+                       } else if (mode == Mode.AUTHOR) {
+                               metas = reader.getLibrary().getList().filter(null, target, null);
+                       } else {
+                               metas = reader.getLibrary().getList().getMetas();
+                       }
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+                       metas = new ArrayList<MetaData>();
+               }
+
+               setMetas(metas);
+       }
+
+       /**
+        * Change the author/source filter and display all stories matching this
+        * target.
+        * 
+        * @param mode
+        *            the new mode or NULL for no sorting
+        * @param target
+        *            the actual target for the given mode, or NULL for all of them
+        */
+       public void setMode(Mode mode, String target) {
+               this.mode = mode;
+               this.target = target;
+               refreshStories();
+       }
+
+       /**
+        * Update the list of stories displayed in this {@link TWindow}.
+        * <p>
+        * If a filter is set, only the stories which pass the filter will be
+        * displayed.
+        * 
+        * @param metas
+        *            the new list of stories to display
+        */
+       private void setMetas(List<MetaData> metas) {
+               listKeys.clear();
+               listItems.clear();
+
+               if (metas != null) {
+                       for (MetaData meta : metas) {
+                               String desc = desc(meta);
+                               if (filter.isEmpty()
+                                               || desc.toLowerCase().contains(filter.toLowerCase())) {
+                                       listKeys.add(meta);
+                                       listItems.add(desc);
+                               }
+                       }
+               }
+
+               list.setList(listItems);
+               if (listItems.size() > 0) {
+                       list.setSelectedIndex(0);
+               }
+       }
+
+       public MetaData getSelectedMeta() {
+               if (list.getSelectedIndex() >= 0) {
+                       return listKeys.get(list.getSelectedIndex());
+               }
+
+               return null;
+       }
+
+       public void readStory(MetaData meta) {
+               try {
+                       reader.setChapter(-1);
+                       reader.setMeta(meta);
+                       reader.read(false);
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+       }
+
+       private String desc(MetaData meta) {
+               return String.format("%5s: %s", meta.getLuid(), meta.getTitle());
+       }
+
+       @Override
+       public void onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderApplication.CMD_EXIT)) {
+                       TuiReaderApplication.close(this);
+               } else {
+                       // Handle our own event if needed here
+                       super.onCommand(command);
+               }
+       }
+
+       @Override
+       public void onMenu(TMenuEvent menu) {
+               MetaData meta = getSelectedMeta();
+               if (meta != null) {
+                       switch (menu.getId()) {
+                       case TuiReaderApplication.MENU_FILE_OPEN:
+                               readStory(meta);
+
+                               return;
+                       case TuiReaderApplication.MENU_FILE_EXPORT:
+
+                               try {
+                                       // TODO: choose type, pg, error
+                                       OutputType outputType = OutputType.EPUB;
+                                       String path = fileOpenBox(".", Type.SAVE);
+                                       reader.getLibrary().export(meta.getLuid(), outputType,
+                                                       path, null);
+                               } catch (IOException e) {
+                                       // TODO
+                                       e.printStackTrace();
+                               }
+
+                               return;
+
+                       case -1:
+                               try {
+                                       reader.getLibrary().delete(meta.getLuid());
+                               } catch (IOException e) {
+                                       // TODO
+                               }
+
+                               return;
+                       }
+               }
+
+               super.onMenu(menu);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TuiReaderOptionWindow.java b/src/be/nikiroo/fanfix_jexer/reader/TuiReaderOptionWindow.java
new file mode 100644 (file)
index 0000000..a02ad99
--- /dev/null
@@ -0,0 +1,15 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import jexer.TStatusBar;
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.UiConfig;
+
+class TuiReaderOptionWindow extends TOptionWindow {
+       public TuiReaderOptionWindow(TuiReaderApplication reader, boolean uiOptions) {
+               super(reader, uiOptions ? UiConfig.class : Config.class,
+                               uiOptions ? Instance.getInstance().getUiConfig() : Instance.getInstance().getConfig(), "Options");
+
+               TStatusBar statusBar = reader.setStatusBar(this, "Options");
+       }
+}
diff --git a/src/be/nikiroo/fanfix_jexer/reader/TuiReaderStoryWindow.java b/src/be/nikiroo/fanfix_jexer/reader/TuiReaderStoryWindow.java
new file mode 100644 (file)
index 0000000..6a6ff7a
--- /dev/null
@@ -0,0 +1,305 @@
+package be.nikiroo.fanfix_jexer.reader;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import jexer.TAction;
+import jexer.TButton;
+import jexer.TLabel;
+import jexer.TText;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TResizeEvent;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.reader.BasicReader;
+import be.nikiroo.jexer.TSizeConstraint;
+import be.nikiroo.jexer.TTable;
+
+/**
+ * This window will contain the {@link Story} in a readable format, with a
+ * chapter browser.
+ * 
+ * @author niki
+ */
+class TuiReaderStoryWindow extends TWindow {
+       private Story story;
+       private TLabel titleField;
+       private TText textField;
+       private TTable table;
+       private int chapter = -99; // invalid value
+       private List<TButton> navigationButtons;
+       private TLabel currentChapter;
+       private List<TSizeConstraint> sizeConstraints = new ArrayList<TSizeConstraint>();
+
+       // chapter: -1 for "none" (0 is desc)
+       public TuiReaderStoryWindow(TuiReaderApplication app, Story story,
+                       int chapter) {
+               super(app, desc(story.getMeta()), 0, 0, 60, 18, CENTERED | RESIZABLE);
+
+               this.story = story;
+
+               app.setStatusBar(this, desc(story.getMeta()));
+
+               // last = use window background
+               titleField = new TLabel(this, "    Title", 0, 1, "tlabel", false);
+               textField = new TText(this, "", 0, 0, 1, 1);
+               table = new TTable(this, 0, 0, 1, 1, null, null, Arrays.asList("Key",
+                               "Value"), true);
+
+               titleField.setEnabled(false);
+
+               navigationButtons = new ArrayList<TButton>(5);
+
+               navigationButtons.add(addButton("<<", 0, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(-1);
+                       }
+               }));
+               navigationButtons.add(addButton("< ", 4, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(TuiReaderStoryWindow.this.chapter - 1);
+                       }
+               }));
+               navigationButtons.add(addButton("> ", 7, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(TuiReaderStoryWindow.this.chapter + 1);
+                       }
+               }));
+               navigationButtons.add(addButton(">>", 10, 0, new TAction() {
+                       @Override
+                       public void DO() {
+                               setChapter(getStory().getChapters().size());
+                       }
+               }));
+
+               navigationButtons.get(0).setEnabled(false);
+               navigationButtons.get(1).setEnabled(false);
+
+               currentChapter = addLabel("", 0, 0);
+
+               TSizeConstraint.setSize(sizeConstraints, textField, 1, 3, -1, -1);
+               TSizeConstraint.setSize(sizeConstraints, table, 0, 3, 0, -1);
+               TSizeConstraint.setSize(sizeConstraints, currentChapter, 14, -3, -1,
+                               null);
+
+               for (TButton navigationButton : navigationButtons) {
+                       navigationButton.setShadowColor(null);
+                       // navigationButton.setEmptyBorders(false);
+                       TSizeConstraint.setSize(sizeConstraints, navigationButton, null,
+                                       -3, null, null);
+               }
+
+               onResize(null);
+
+               setChapter(chapter);
+       }
+
+       @Override
+       public void onResize(TResizeEvent resize) {
+               if (resize != null) {
+                       super.onResize(resize);
+               }
+
+               // TODO: find out why TText and TTable does not behave the same way
+               // (offset of 2 for height and width)
+
+               TSizeConstraint.resize(sizeConstraints);
+
+               // Improve the disposition of the scrollbars
+               textField.getVerticalScroller().setX(textField.getWidth());
+               textField.getVerticalScroller().setHeight(textField.getHeight());
+               textField.getHorizontalScroller().setX(-1);
+               textField.getHorizontalScroller().setWidth(textField.getWidth() + 1);
+
+               setCurrentChapterText();
+       }
+
+       /**
+        * Display the current chapter in the window, or the {@link Story} info
+        * page.
+        * 
+        * @param chapter
+        *            the chapter (including "0" which is the description) or "-1"
+        *            to display the info page instead
+        */
+       private void setChapter(int chapter) {
+               if (chapter > getStory().getChapters().size()) {
+                       chapter = getStory().getChapters().size();
+               }
+
+               if (chapter != this.chapter) {
+                       this.chapter = chapter;
+
+                       int max = getStory().getChapters().size();
+                       navigationButtons.get(0).setEnabled(chapter > -1);
+                       navigationButtons.get(1).setEnabled(chapter > -1);
+                       navigationButtons.get(2).setEnabled(chapter < max);
+                       navigationButtons.get(3).setEnabled(chapter < max);
+
+                       if (chapter < 0) {
+                               displayInfoPage();
+                       } else {
+                               displayChapterPage();
+                       }
+               }
+
+               setCurrentChapterText();
+       }
+
+       /**
+        * Append the info page about the current {@link Story}.
+        * 
+        * @param builder
+        *            the builder to append to
+        */
+       private void displayInfoPage() {
+               textField.setVisible(false);
+               table.setVisible(true);
+               textField.setEnabled(false);
+               table.setEnabled(true);
+
+               MetaData meta = getStory().getMeta();
+
+               setCurrentTitle(meta.getTitle());
+
+               Map<String, String> metaDesc = BasicReader.getMetaDesc(meta);
+               String[][] metaDescObj = new String[metaDesc.size()][2];
+               int i = 0;
+               for (String key : metaDesc.keySet()) {
+                       metaDescObj[i][0] = " " + key;
+                       metaDescObj[i][1] = metaDesc.get(key);
+                       i++;
+               }
+
+               table.setRowData(metaDescObj);
+               table.setHeaders(Arrays.asList("key", "value"), false);
+               table.toTop();
+       }
+
+       /**
+        * Append the current chapter.
+        * 
+        * @param builder
+        *            the builder to append to
+        */
+       private void displayChapterPage() {
+               table.setVisible(false);
+               textField.setVisible(true);
+               table.setEnabled(false);
+               textField.setEnabled(true);
+
+               StringBuilder builder = new StringBuilder();
+
+               Chapter chap = null;
+               if (chapter == 0) {
+                       chap = getStory().getMeta().getResume();
+               } else if (chapter > 0) {
+                       chap = getStory().getChapters().get(chapter - 1);
+               }
+
+               // TODO: i18n
+               String chapName = chap == null ? "[No RESUME]" : chap.getName();
+               setCurrentTitle(String.format("Chapter %d: %s", chapter, chapName));
+
+               if (chap != null) {
+                       for (Paragraph para : chap) {
+                               if (para.getType() == ParagraphType.BREAK) {
+                                       builder.append("\n");
+                               }
+                               builder.append(para.getContent()).append("\n");
+                               if (para.getType() == ParagraphType.BREAK) {
+                                       builder.append("\n");
+                               }
+                       }
+               }
+
+               setText(builder.toString());
+       }
+
+       private Story getStory() {
+               return story;
+       }
+
+       /**
+        * Display the given text on the window.
+        * 
+        * @param text
+        *            the text to display
+        */
+       private void setText(String text) {
+               textField.setText(text);
+               textField.reflowData();
+               textField.toTop();
+       }
+
+       /**
+        * Set the current chapter area to the correct value.
+        */
+       private void setCurrentChapterText() {
+               String name;
+               if (chapter < 0) {
+                       name = " " + getStory().getMeta().getTitle();
+               } else if (chapter == 0) {
+                       Chapter resume = getStory().getMeta().getResume();
+                       if (resume != null) {
+                               name = String.format(" %s", resume.getName());
+                       } else {
+                               // TODO: i18n
+                               name = "[No RESUME]";
+                       }
+               } else {
+                       int max = getStory().getChapters().size();
+                       Chapter chap = getStory().getChapters().get(chapter - 1);
+                       name = String.format(" %d/%d: %s", chapter, max, chap.getName());
+               }
+
+               int width = getWidth() - currentChapter.getX();
+               name = String.format("%-" + width + "s", name);
+               if (name.length() > width) {
+                       name = name.substring(0, width);
+               }
+
+               currentChapter.setLabel(name);
+       }
+
+       /**
+        * Set the current title in-window.
+        * 
+        * @param title
+        *            the new title
+        */
+       private void setCurrentTitle(String title) {
+               String pad = "";
+               if (title.length() < getWidth()) {
+                       int padSize = (getWidth() - title.length()) / 2;
+                       pad = String.format("%" + padSize + "s", "");
+               }
+
+               title = pad + title + pad;
+               titleField.setWidth(title.length());
+               titleField.setLabel(title);
+       }
+
+       private static String desc(MetaData meta) {
+               return String.format("%s: %s", meta.getLuid(), meta.getTitle());
+       }
+
+       @Override
+       public void onCommand(TCommandEvent command) {
+               if (command.getCmd().equals(TuiReaderApplication.CMD_EXIT)) {
+                       TuiReaderApplication.close(this);
+               } else {
+                       // Handle our own event if needed here
+                       super.onCommand(command);
+               }
+       }
+}