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