Merge commit '8b2627ce767579eb616e262b3f45f810a88ec200'
[nikiroo-utils.git] / src / be / nikiroo / fanfix / reader / ui / GuiReaderMainPanel.java
1 package be.nikiroo.fanfix.reader.ui;
2
3 import java.awt.BorderLayout;
4 import java.awt.Color;
5 import java.awt.Component;
6 import java.awt.EventQueue;
7 import java.awt.Frame;
8 import java.awt.Toolkit;
9 import java.awt.datatransfer.DataFlavor;
10 import java.awt.event.ActionEvent;
11 import java.awt.event.ActionListener;
12 import java.awt.event.FocusAdapter;
13 import java.awt.event.FocusEvent;
14 import java.io.File;
15 import java.io.IOException;
16 import java.lang.reflect.InvocationTargetException;
17 import java.net.URL;
18 import java.net.UnknownHostException;
19 import java.util.ArrayList;
20 import java.util.LinkedList;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.TreeMap;
24
25 import javax.swing.BoxLayout;
26 import javax.swing.JFileChooser;
27 import javax.swing.JLabel;
28 import javax.swing.JMenuBar;
29 import javax.swing.JOptionPane;
30 import javax.swing.JPanel;
31 import javax.swing.JPopupMenu;
32 import javax.swing.JScrollPane;
33 import javax.swing.SwingConstants;
34 import javax.swing.SwingUtilities;
35
36 import be.nikiroo.fanfix.Instance;
37 import be.nikiroo.fanfix.bundles.StringIdGui;
38 import be.nikiroo.fanfix.bundles.UiConfig;
39 import be.nikiroo.fanfix.data.MetaData;
40 import be.nikiroo.fanfix.data.Story;
41 import be.nikiroo.fanfix.library.BasicLibrary;
42 import be.nikiroo.fanfix.library.BasicLibrary.Status;
43 import be.nikiroo.fanfix.library.LocalLibrary;
44 import be.nikiroo.fanfix.reader.BasicReader;
45 import be.nikiroo.fanfix.reader.ui.GuiReaderBook.BookActionListener;
46 import be.nikiroo.fanfix.reader.ui.GuiReaderBookInfo.Type;
47 import be.nikiroo.utils.Progress;
48 import be.nikiroo.utils.ui.ProgressBar;
49
50 /**
51 * A {@link Frame} that will show a {@link GuiReaderBook} item for each
52 * {@link Story} in the main cache ({@link Instance#getCache()}), and offer a
53 * way to copy them to the {@link GuiReader} cache (
54 * {@link BasicReader#getLibrary()}), read them, delete them...
55 *
56 * @author niki
57 */
58 class GuiReaderMainPanel extends JPanel {
59 private static final long serialVersionUID = 1L;
60 private FrameHelper helper;
61 private Map<String, GuiReaderGroup> books;
62 private GuiReaderGroup bookPane; // for more "All"
63 private JPanel pane;
64 private Color color;
65 private ProgressBar pgBar;
66 private JMenuBar bar;
67 private GuiReaderBook selectedBook;
68 private boolean words; // words or authors (secondary info on books)
69 private boolean currentType; // type/source or author mode (All and Listing)
70
71 /**
72 * An object that offers some helper methods to access the frame that host
73 * it and the Fanfix-related functions.
74 *
75 * @author niki
76 */
77 public interface FrameHelper {
78 /**
79 * Return the reader associated to this {@link FrameHelper}.
80 *
81 * @return the reader
82 */
83 public GuiReader getReader();
84
85 /**
86 * Create the main menu bar.
87 * <p>
88 * Will invalidate the layout.
89 *
90 * @param status
91 * the library status, <b>must not</b> be NULL
92 */
93 public void createMenu(Status status);
94
95 /**
96 * Create a popup menu for a {@link GuiReaderBook} that represents a
97 * story.
98 *
99 * @return the popup menu to display
100 */
101 public JPopupMenu createBookPopup();
102
103 /**
104 * Create a popup menu for a {@link GuiReaderBook} that represents a
105 * source/type or an author.
106 *
107 * @return the popup menu to display
108 */
109 public JPopupMenu createSourceAuthorPopup();
110 }
111
112 /**
113 * A {@link Runnable} with a {@link MetaData} parameter.
114 *
115 * @author niki
116 */
117 public interface MetaDataRunnable {
118 /**
119 * Run the action.
120 *
121 * @param meta
122 * the meta of the story
123 */
124 public void run(MetaData meta);
125 }
126
127 /**
128 * Create a new {@link GuiReaderMainPanel}.
129 *
130 * @param parent
131 * the associated {@link FrameHelper} to forward some commands
132 * and access its {@link LocalLibrary}
133 * @param type
134 * the type of {@link Story} to load, or NULL for all types
135 */
136 public GuiReaderMainPanel(FrameHelper parent, String type) {
137 super(new BorderLayout(), true);
138
139 this.helper = parent;
140
141 pane = new JPanel();
142 pane.setLayout(new BoxLayout(pane, BoxLayout.PAGE_AXIS));
143 JScrollPane scroll = new JScrollPane(pane);
144
145 Integer icolor = Instance.getInstance().getUiConfig().getColor(UiConfig.BACKGROUND_COLOR);
146 if (icolor != null) {
147 color = new Color(icolor);
148 setBackground(color);
149 pane.setBackground(color);
150 scroll.setBackground(color);
151 }
152
153 scroll.getVerticalScrollBar().setUnitIncrement(16);
154 add(scroll, BorderLayout.CENTER);
155
156 String message = parent.getReader().getLibrary().getLibraryName();
157 if (!message.isEmpty()) {
158 JLabel name = new JLabel(message, SwingConstants.CENTER);
159 add(name, BorderLayout.NORTH);
160 }
161
162 pgBar = new ProgressBar();
163 add(pgBar, BorderLayout.SOUTH);
164
165 pgBar.addActionListener(new ActionListener() {
166 @Override
167 public void actionPerformed(ActionEvent e) {
168 pgBar.invalidate();
169 pgBar.setProgress(null);
170 setEnabled(true);
171 validate();
172 }
173 });
174
175 pgBar.addUpdateListener(new ActionListener() {
176 @Override
177 public void actionPerformed(ActionEvent e) {
178 pgBar.invalidate();
179 validate();
180 repaint();
181 }
182 });
183
184 books = new TreeMap<String, GuiReaderGroup>();
185
186 addFocusListener(new FocusAdapter() {
187 @Override
188 public void focusGained(FocusEvent e) {
189 focus();
190 }
191 });
192
193 pane.setVisible(false);
194 final Progress pg = new Progress();
195 final String typeF = type;
196 outOfUi(pg, true, new Runnable() {
197 @Override
198 public void run() {
199 final BasicLibrary lib = helper.getReader().getLibrary();
200 final Status status = lib.getStatus();
201
202 if (status == Status.READ_WRITE) {
203 lib.refresh(pg);
204 }
205
206 inUi(new Runnable() {
207 @Override
208 public void run() {
209 if (status.isReady()) {
210 helper.createMenu(status);
211 pane.setVisible(true);
212 if (typeF == null) {
213 try {
214 addBookPane(true, false);
215 } catch (IOException e) {
216 error(e.getLocalizedMessage(),
217 "IOException", e);
218 }
219 } else {
220 addBookPane(typeF, true);
221 }
222 } else {
223 helper.createMenu(status);
224 validate();
225
226 String desc = Instance.getInstance().getTransGui().getStringX(StringIdGui.ERROR_LIB_STATUS,
227 status.toString());
228 if (desc == null) {
229 desc = GuiReader
230 .trans(StringIdGui.ERROR_LIB_STATUS);
231 }
232
233 String err = lib.getLibraryName() + "\n" + desc;
234 error(err, GuiReader
235 .trans(StringIdGui.TITLE_ERROR_LIBRARY),
236 null);
237 }
238 }
239 });
240 }
241 });
242 }
243
244 public boolean getCurrentType() {
245 return currentType;
246 }
247
248 /**
249 * Add a new {@link GuiReaderGroup} on the frame to display all the
250 * sources/types or all the authors, or a listing of all the books sorted
251 * either by source or author.
252 * <p>
253 * A display of all the sources/types or all the authors will show one icon
254 * per source/type or author.
255 * <p>
256 * A listing of all the books sorted by source/type or author will display
257 * all the books.
258 *
259 * @param type
260 * TRUE for type/source, FALSE for author
261 * @param listMode
262 * TRUE to get a listing of all the sources or authors, FALSE to
263 * get one icon per source or author
264 *
265 * @throws IOException
266 * in case of I/O error
267 */
268 public void addBookPane(boolean type, boolean listMode) throws IOException {
269 this.currentType = type;
270 BasicLibrary lib = helper.getReader().getLibrary();
271 if (type) {
272 if (!listMode) {
273 addListPane(GuiReader.trans(StringIdGui.MENU_SOURCES),
274 lib.getSources(), type);
275 } else {
276 for (String tt : lib.getSources()) {
277 if (tt != null) {
278 addBookPane(tt, type);
279 }
280 }
281 }
282 } else {
283 if (!listMode) {
284 addListPane(GuiReader.trans(StringIdGui.MENU_AUTHORS),
285 lib.getAuthors(), type);
286 } else {
287 for (String tt : lib.getAuthors()) {
288 if (tt != null) {
289 addBookPane(tt, type);
290 }
291 }
292 }
293 }
294 }
295
296 /**
297 * Add a new {@link GuiReaderGroup} on the frame to display the books of the
298 * selected type or author.
299 * <p>
300 * Will invalidate the layout.
301 *
302 * @param value
303 * the author or the type, or NULL to get all the
304 * authors-or-types
305 * @param type
306 * TRUE for type/source, FALSE for author
307 */
308 public void addBookPane(String value, boolean type) {
309 this.currentType = type;
310
311 GuiReaderGroup bookPane = new GuiReaderGroup(helper.getReader(), value,
312 color);
313
314 books.put(value, bookPane);
315
316 pane.invalidate();
317 pane.add(bookPane);
318
319 bookPane.setActionListener(new BookActionListener() {
320 @Override
321 public void select(GuiReaderBook book) {
322 selectedBook = book;
323 }
324
325 @Override
326 public void popupRequested(GuiReaderBook book, Component target,
327 int x, int y) {
328 JPopupMenu popup = helper.createBookPopup();
329 popup.show(target, x, y);
330 }
331
332 @Override
333 public void action(final GuiReaderBook book) {
334 openBook(book);
335 }
336 });
337
338 focus();
339 }
340
341 /**
342 * Clear the pane from any book that may be present, usually prior to adding
343 * new ones.
344 * <p>
345 * Will invalidate the layout.
346 */
347 public void removeBookPanes() {
348 books.clear();
349 pane.invalidate();
350 pane.removeAll();
351 }
352
353 /**
354 * Refresh the list of {@link GuiReaderBook}s from disk.
355 * <p>
356 * Will validate the layout, as it is a "refresh" operation.
357 */
358 public void refreshBooks() {
359 BasicLibrary lib = helper.getReader().getLibrary();
360 for (String value : books.keySet()) {
361 List<GuiReaderBookInfo> infos = new ArrayList<GuiReaderBookInfo>();
362
363 List<MetaData> metas;
364 try {
365 if (currentType) {
366 metas = lib.getList().filter(value, null, null);
367 } else {
368 metas = lib.getList().filter(null, value, null);
369 }
370 } catch (IOException e) {
371 error(e.getLocalizedMessage(), "IOException", e);
372 metas = new ArrayList<MetaData>();
373 }
374
375 for (MetaData meta : metas) {
376 infos.add(GuiReaderBookInfo.fromMeta(meta));
377 }
378
379 books.get(value).refreshBooks(infos, words);
380 }
381
382 if (bookPane != null) {
383 bookPane.refreshBooks(words);
384 }
385
386 this.validate();
387 }
388
389 /**
390 * Open a {@link GuiReaderBook} item.
391 *
392 * @param book
393 * the {@link GuiReaderBook} to open
394 */
395 public void openBook(final GuiReaderBook book) {
396 final Progress pg = new Progress();
397 outOfUi(pg, false, new Runnable() {
398 @Override
399 public void run() {
400 try {
401 helper.getReader().read(book.getInfo().getMeta().getLuid(),
402 false, pg);
403 SwingUtilities.invokeLater(new Runnable() {
404 @Override
405 public void run() {
406 book.setCached(true);
407 }
408 });
409 } catch (IOException e) {
410 Instance.getInstance().getTraceHandler().error(e);
411 error(GuiReader.trans(StringIdGui.ERROR_CANNOT_OPEN),
412 GuiReader.trans(StringIdGui.TITLE_ERROR), e);
413 }
414 }
415 });
416 }
417
418 /**
419 * Prefetch a {@link GuiReaderBook} item (which can be a group, in which
420 * case we prefetch all its members).
421 *
422 * @param book
423 * the {@link GuiReaderBook} to open
424 */
425 public void prefetchBook(final GuiReaderBook book) {
426 final List<String> luids = new LinkedList<String>();
427 try {
428 switch (book.getInfo().getType()) {
429 case STORY:
430 luids.add(book.getInfo().getMeta().getLuid());
431 break;
432 case SOURCE:
433 for (MetaData meta : helper.getReader().getLibrary()
434 .getList().filter(book.getInfo().getMainInfo(), null, null)) {
435 luids.add(meta.getLuid());
436 }
437 break;
438 case AUTHOR:
439 for (MetaData meta : helper.getReader().getLibrary()
440 .getList().filter(null, book.getInfo().getMainInfo(), null)) {
441 luids.add(meta.getLuid());
442 }
443 break;
444 }
445 } catch (IOException e) {
446 Instance.getInstance().getTraceHandler().error(e);
447 }
448
449 final Progress pg = new Progress();
450 pg.setMax(luids.size());
451
452 outOfUi(pg, false, new Runnable() {
453 @Override
454 public void run() {
455 try {
456 for (String luid : luids) {
457 Progress pgStep = new Progress();
458 pg.addProgress(pgStep, 1);
459
460 helper.getReader().prefetch(luid, pgStep);
461 }
462
463 // TODO: also set the green button on sources/authors?
464 // requires to do the same when all stories inside are green
465 if (book.getInfo().getType() == Type.STORY) {
466 SwingUtilities.invokeLater(new Runnable() {
467 @Override
468 public void run() {
469 book.setCached(true);
470 }
471 });
472 }
473 } catch (IOException e) {
474 Instance.getInstance().getTraceHandler().error(e);
475 error(GuiReader.trans(StringIdGui.ERROR_CANNOT_OPEN),
476 GuiReader.trans(StringIdGui.TITLE_ERROR), e);
477 }
478 }
479 });
480 }
481
482 /**
483 * Process the given action out of the Swing UI thread and link the given
484 * {@link ProgressBar} to the action.
485 * <p>
486 * The code will make sure that the {@link ProgressBar} (if not NULL) is set
487 * to done when the action is done.
488 *
489 * @param progress
490 * the {@link ProgressBar} or NULL
491 * @param refreshBooks
492 * TRUE to refresh the books after
493 * @param run
494 * the action to run
495 */
496 public void outOfUi(Progress progress, final boolean refreshBooks,
497 final Runnable run) {
498 final Progress pg = new Progress();
499 final Progress reload = new Progress(
500 GuiReader.trans(StringIdGui.PROGRESS_OUT_OF_UI_RELOAD_BOOKS));
501
502 if (progress == null) {
503 progress = new Progress();
504 }
505
506 if (refreshBooks) {
507 pg.addProgress(progress, 100);
508 } else {
509 pg.addProgress(progress, 90);
510 pg.addProgress(reload, 10);
511 }
512
513 invalidate();
514 pgBar.setProgress(pg);
515 validate();
516 setEnabled(false);
517
518 new Thread(new Runnable() {
519 @Override
520 public void run() {
521 try {
522 run.run();
523 if (refreshBooks) {
524 refreshBooks();
525 }
526 } finally {
527 reload.done();
528 if (!pg.isDone()) {
529 // will trigger pgBar ActionListener:
530 pg.done();
531 }
532 }
533 }
534 }, "outOfUi thread").start();
535 }
536
537 /**
538 * Process the given action in the main Swing UI thread.
539 * <p>
540 * The code will make sure the current thread is the main UI thread and, if
541 * not, will switch to it before executing the runnable.
542 * <p>
543 * Synchronous operation.
544 *
545 * @param run
546 * the action to run
547 */
548 public void inUi(final Runnable run) {
549 if (EventQueue.isDispatchThread()) {
550 run.run();
551 } else {
552 try {
553 EventQueue.invokeAndWait(run);
554 } catch (InterruptedException e) {
555 Instance.getInstance().getTraceHandler().error(e);
556 } catch (InvocationTargetException e) {
557 Instance.getInstance().getTraceHandler().error(e);
558 }
559 }
560 }
561
562 /**
563 * Import a {@link Story} into the main {@link LocalLibrary}.
564 * <p>
565 * Should be called inside the UI thread.
566 *
567 * @param askUrl
568 * TRUE for an {@link URL}, false for a {@link File}
569 */
570 public void imprt(boolean askUrl) {
571 JFileChooser fc = new JFileChooser();
572
573 Object url;
574 if (askUrl) {
575 String clipboard = "";
576 try {
577 clipboard = ("" + Toolkit.getDefaultToolkit()
578 .getSystemClipboard().getData(DataFlavor.stringFlavor))
579 .trim();
580 } catch (Exception e) {
581 // No data will be handled
582 }
583
584 if (clipboard == null || !(clipboard.startsWith("http://") || //
585 clipboard.startsWith("https://"))) {
586 clipboard = "";
587 }
588
589 url = JOptionPane.showInputDialog(GuiReaderMainPanel.this,
590 GuiReader.trans(StringIdGui.SUBTITLE_IMPORT_URL),
591 GuiReader.trans(StringIdGui.TITLE_IMPORT_URL),
592 JOptionPane.QUESTION_MESSAGE, null, null, clipboard);
593 } else if (fc.showOpenDialog(this) != JFileChooser.CANCEL_OPTION) {
594 url = fc.getSelectedFile().getAbsolutePath();
595 } else {
596 url = null;
597 }
598
599 if (url != null && !url.toString().isEmpty()) {
600 imprt(url.toString(), null, null);
601 }
602 }
603
604 /**
605 * Actually import the {@link Story} into the main {@link LocalLibrary}.
606 * <p>
607 * Should be called inside the UI thread.
608 *
609 * @param url
610 * the {@link Story} to import by {@link URL}
611 * @param onSuccess
612 * Action to execute on success
613 * @param onSuccessPgName
614 * the name to use for the onSuccess progress bar
615 */
616 public void imprt(final String url, final MetaDataRunnable onSuccess,
617 String onSuccessPgName) {
618 final Progress pg = new Progress();
619 final Progress pgImprt = new Progress();
620 final Progress pgOnSuccess = new Progress(onSuccessPgName);
621 pg.addProgress(pgImprt, 95);
622 pg.addProgress(pgOnSuccess, 5);
623
624 outOfUi(pg, true, new Runnable() {
625 @Override
626 public void run() {
627 Exception ex = null;
628 MetaData meta = null;
629 try {
630 meta = helper.getReader().getLibrary()
631 .imprt(BasicReader.getUrl(url), pgImprt);
632 } catch (IOException e) {
633 ex = e;
634 }
635
636 final Exception e = ex;
637
638 final boolean ok = (e == null);
639
640 pgOnSuccess.setProgress(0);
641 if (!ok) {
642 if (e instanceof UnknownHostException) {
643 error(GuiReader.trans(
644 StringIdGui.ERROR_URL_NOT_SUPPORTED, url),
645 GuiReader.trans(StringIdGui.TITLE_ERROR), null);
646 } else {
647 error(GuiReader.trans(
648 StringIdGui.ERROR_URL_IMPORT_FAILED, url,
649 e.getMessage()), GuiReader
650 .trans(StringIdGui.TITLE_ERROR), e);
651 }
652 } else {
653 if (onSuccess != null) {
654 onSuccess.run(meta);
655 }
656 }
657 pgOnSuccess.done();
658 }
659 });
660 }
661
662 /**
663 * Enables or disables this component, depending on the value of the
664 * parameter <code>b</code>. An enabled component can respond to user input
665 * and generate events. Components are enabled initially by default.
666 * <p>
667 * Enabling or disabling <b>this</b> component will also affect its
668 * children.
669 *
670 * @param b
671 * If <code>true</code>, this component is enabled; otherwise
672 * this component is disabled
673 */
674 @Override
675 public void setEnabled(boolean b) {
676 if (bar != null) {
677 bar.setEnabled(b);
678 }
679
680 for (GuiReaderGroup group : books.values()) {
681 group.setEnabled(b);
682 }
683 super.setEnabled(b);
684 repaint();
685 }
686
687 public void setWords(boolean words) {
688 this.words = words;
689 }
690
691 public GuiReaderBook getSelectedBook() {
692 return selectedBook;
693 }
694
695 public void unsetSelectedBook() {
696 selectedBook = null;
697 }
698
699 private void addListPane(String name, List<String> values,
700 final boolean type) {
701 GuiReader reader = helper.getReader();
702 BasicLibrary lib = reader.getLibrary();
703
704 bookPane = new GuiReaderGroup(reader, name, color);
705
706 List<GuiReaderBookInfo> infos = new ArrayList<GuiReaderBookInfo>();
707 for (String value : values) {
708 if (type) {
709 infos.add(GuiReaderBookInfo.fromSource(lib, value));
710 } else {
711 infos.add(GuiReaderBookInfo.fromAuthor(lib, value));
712 }
713 }
714
715 bookPane.refreshBooks(infos, words);
716
717 this.invalidate();
718 pane.invalidate();
719 pane.add(bookPane);
720 pane.validate();
721 this.validate();
722
723 bookPane.setActionListener(new BookActionListener() {
724 @Override
725 public void select(GuiReaderBook book) {
726 selectedBook = book;
727 }
728
729 @Override
730 public void popupRequested(GuiReaderBook book, Component target,
731 int x, int y) {
732 JPopupMenu popup = helper.createSourceAuthorPopup();
733 popup.show(target, x, y);
734 }
735
736 @Override
737 public void action(final GuiReaderBook book) {
738 removeBookPanes();
739 addBookPane(book.getInfo().getMainInfo(), type);
740 refreshBooks();
741 }
742 });
743
744 focus();
745 }
746
747 /**
748 * Focus the first {@link GuiReaderGroup} we find.
749 */
750 private void focus() {
751 GuiReaderGroup group = null;
752 Map<String, GuiReaderGroup> books = this.books;
753 if (books.size() > 0) {
754 group = books.values().iterator().next();
755 }
756
757 if (group == null) {
758 group = bookPane;
759 }
760
761 if (group != null) {
762 group.requestFocusInWindow();
763 }
764 }
765
766 /**
767 * Display an error message and log the linked {@link Exception}.
768 *
769 * @param message
770 * the message
771 * @param title
772 * the title of the error message
773 * @param e
774 * the exception to log if any
775 */
776 private void error(final String message, final String title, Exception e) {
777 Instance.getInstance().getTraceHandler().error(title + ": " + message);
778 if (e != null) {
779 Instance.getInstance().getTraceHandler().error(e);
780 }
781
782 SwingUtilities.invokeLater(new Runnable() {
783 @Override
784 public void run() {
785 JOptionPane.showMessageDialog(GuiReaderMainPanel.this, message,
786 title, JOptionPane.ERROR_MESSAGE);
787 }
788 });
789 }
790 }