From 4941d2d6184cb5d59c2932411a40631db8b429d1 Mon Sep 17 00:00:00 2001 From: Kevin Lamonte Date: Tue, 26 Nov 2019 16:17:46 -0600 Subject: [PATCH] help system --- resources/help.xml | 60 ++++ src/jexer/TApplication.java | 86 ++++++ src/jexer/TApplication.properties | 3 + src/jexer/TEditorWidget.java | 6 + src/jexer/TExceptionDialog.properties | 8 +- src/jexer/THelpWindow.java | 271 ++++++++++++++++++ src/jexer/THelpWindow.properties | 5 + src/jexer/TList.java | 3 + src/jexer/TTerminalWindow.java | 28 +- src/jexer/TTerminalWindow.properties | 2 + src/jexer/TText.java | 14 +- src/jexer/TWindow.java | 19 +- src/jexer/bits/ColorTheme.java | 42 +++ src/jexer/bits/StringUtils.java | 4 + src/jexer/help/HelpFile.java | 381 +++++++++++++++++++++++++ src/jexer/help/HelpFile.properties | 3 + src/jexer/help/Link.java | 135 +++++++++ src/jexer/help/THelpText.java | 389 ++++++++++++++++++++++++++ src/jexer/help/TParagraph.java | 175 ++++++++++++ src/jexer/help/TWord.java | 143 ++++++++++ src/jexer/help/Topic.java | 339 ++++++++++++++++++++++ src/jexer/help/Topic.properties | 2 + src/jexer/help/package-info.java | 33 +++ 23 files changed, 2139 insertions(+), 12 deletions(-) create mode 100644 resources/help.xml create mode 100644 src/jexer/THelpWindow.java create mode 100644 src/jexer/THelpWindow.properties create mode 100644 src/jexer/help/HelpFile.java create mode 100644 src/jexer/help/HelpFile.properties create mode 100644 src/jexer/help/Link.java create mode 100644 src/jexer/help/THelpText.java create mode 100644 src/jexer/help/TParagraph.java create mode 100644 src/jexer/help/TWord.java create mode 100644 src/jexer/help/Topic.java create mode 100644 src/jexer/help/Topic.properties create mode 100644 src/jexer/help/package-info.java diff --git a/resources/help.xml b/resources/help.xml new file mode 100644 index 00000000..7c68c02c --- /dev/null +++ b/resources/help.xml @@ -0,0 +1,60 @@ + + + + Jexer Help File + Kevin Lamonte + 1.0.0 + Jan 1, 2020 + + + + This [window](Windows) does not have a specific help topic. + See [here](Help On Help) for general information on using the + help system. + + + + + The #{help} system... + + + + + + + #{Menus} do ... + + + + + + #{Windows} do ... + + + + + + The #{text editing} [window](Windows)... + + + + + + + + + + The terminal window ... + + + + + + Copyright (C) 2019 Kevin Lamonte + + Available to all under the MIT License. + + + + + diff --git a/src/jexer/TApplication.java b/src/jexer/TApplication.java index e7ba0f85..e2c3cd68 100644 --- a/src/jexer/TApplication.java +++ b/src/jexer/TApplication.java @@ -29,6 +29,7 @@ package jexer; import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; import java.io.IOException; import java.io.OutputStream; @@ -62,6 +63,8 @@ import jexer.backend.Screen; import jexer.backend.SwingBackend; import jexer.backend.ECMA48Backend; import jexer.backend.TWindowBackend; +import jexer.help.HelpFile; +import jexer.help.Topic; import jexer.menu.TMenu; import jexer.menu.TMenuItem; import jexer.menu.TSubMenu; @@ -346,6 +349,16 @@ public class TApplication implements Runnable { */ private int screenSelectionY1; + /** + * The help file data. Note package private access. + */ + HelpFile helpFile; + + /** + * The stack of help topics. Note package private access. + */ + ArrayList helpTopics = new ArrayList(); + /** * WidgetEventHandler is the main event consumer loop. There are at most * two such threads in existence: the primary for normal case and a @@ -794,6 +807,14 @@ public class TApplication implements Runnable { } } + // Load the help system + try { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + helpFile = new HelpFile(); + helpFile.load(loader.getResourceAsStream("help.xml")); + } catch (Exception e) { + new TExceptionDialog(this, e); + } } // ------------------------------------------------------------------------ @@ -925,6 +946,15 @@ public class TApplication implements Runnable { return true; } + if (command.equals(cmHelp)) { + if (getActiveWindow() != null) { + new THelpWindow(this, getActiveWindow().getHelpTopic()); + } else { + new THelpWindow(this); + } + return true; + } + if (command.equals(cmShell)) { openTerminal(0, 0, TWindow.RESIZABLE); return true; @@ -976,6 +1006,62 @@ public class TApplication implements Runnable { return true; } + if (menu.getId() == TMenu.MID_HELP_HELP) { + new THelpWindow(this, THelpWindow.HELP_HELP); + return true; + } + + if (menu.getId() == TMenu.MID_HELP_CONTENTS) { + new THelpWindow(this, helpFile.getTableOfContents()); + return true; + } + + if (menu.getId() == TMenu.MID_HELP_INDEX) { + new THelpWindow(this, helpFile.getIndex()); + return true; + } + + if (menu.getId() == TMenu.MID_HELP_SEARCH) { + TInputBox inputBox = inputBox(i18n. + getString("searchHelpInputBoxTitle"), + i18n.getString("searchHelpInputBoxCaption"), "", + TInputBox.Type.OKCANCEL); + if (inputBox.isOk()) { + new THelpWindow(this, + helpFile.getSearchResults(inputBox.getText())); + } + return true; + } + + if (menu.getId() == TMenu.MID_HELP_PREVIOUS) { + if (helpTopics.size() > 1) { + Topic previous = helpTopics.remove(helpTopics.size() - 2); + helpTopics.remove(helpTopics.size() - 1); + new THelpWindow(this, previous); + } else { + new THelpWindow(this, helpFile.getTableOfContents()); + } + return true; + } + + if (menu.getId() == TMenu.MID_HELP_ACTIVE_FILE) { + try { + List filters = new ArrayList(); + filters.add("^.*\\.[Xx][Mm][Ll]$"); + String filename = fileOpenBox(".", TFileOpenBox.Type.OPEN, + filters); + if (filename != null) { + helpTopics = new ArrayList(); + helpFile = new HelpFile(); + helpFile.load(new FileInputStream(filename)); + } + } catch (Exception e) { + // Show this exception to the user. + new TExceptionDialog(this, e); + } + return true; + } + if (menu.getId() == TMenu.MID_SHELL) { openTerminal(0, 0, TWindow.RESIZABLE); return true; diff --git a/src/jexer/TApplication.properties b/src/jexer/TApplication.properties index 299c6a3a..57f7c595 100644 --- a/src/jexer/TApplication.properties +++ b/src/jexer/TApplication.properties @@ -25,3 +25,6 @@ exitDialogText=Exit application? aboutDialogTitle=About aboutDialogText=Jexer Version {0} + +searchHelpInputBoxTitle=Search Help Topics +searchHelpInputBoxCaption=Search help topics for (regex): diff --git a/src/jexer/TEditorWidget.java b/src/jexer/TEditorWidget.java index 6d24a196..6ff39e61 100644 --- a/src/jexer/TEditorWidget.java +++ b/src/jexer/TEditorWidget.java @@ -312,6 +312,12 @@ public class TEditorWidget extends TWidget implements EditMenuUser { // Non-shifted navigation keys disable selection. inSelection = false; } + if ((selectionColumn0 == selectionColumn1) + && (selectionLine0 == selectionLine1) + ) { + // The user clicked a spot and started typing. + inSelection = false; + } } if (keypress.equals(kbLeft) diff --git a/src/jexer/TExceptionDialog.properties b/src/jexer/TExceptionDialog.properties index d07998cf..9e5857a6 100644 --- a/src/jexer/TExceptionDialog.properties +++ b/src/jexer/TExceptionDialog.properties @@ -1,10 +1,10 @@ windowTitle=Java Exception Caught statusBar=Exception -captionLine1=An error has occurred. This may be due to a programming bug, but -captionLine2=could also be a correctable or temporary issue. The stack trace -captionLine3=is reported below. If you wish to submit a bug report, please -captionLine4=use the Save button to create a more detailed error log. +captionLine1=An error has occurred. This may be due to a programming bug, but could +captionLine2=also be a correctable or temporary issue. The stack trace is reported +captionLine3=below. If you wish to submit a bug report, please use the Save button +captionLine4=to create a more detailed error log. exceptionString={0}: {1} diff --git a/src/jexer/THelpWindow.java b/src/jexer/THelpWindow.java new file mode 100644 index 00000000..ee7ce544 --- /dev/null +++ b/src/jexer/THelpWindow.java @@ -0,0 +1,271 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer; + +import java.util.ResourceBundle; + +import jexer.bits.CellAttributes; +import jexer.event.TResizeEvent; +import jexer.help.THelpText; +import jexer.help.Topic; + +/** + * THelpWindow + */ +public class THelpWindow extends TWindow { + + /** + * Translated strings. + */ + private static final ResourceBundle i18n = ResourceBundle.getBundle(THelpWindow.class.getName()); + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + // Default help topic keys. Note package private access. + static String HELP_HELP = "Help On Help"; + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The help text window. + */ + private THelpText helpText; + + /** + * The "Contents" button. + */ + private TButton contentsButton; + + /** + * The "Index" button. + */ + private TButton indexButton; + + /** + * The "Previous" button. + */ + private TButton previousButton; + + /** + * The "Close" button. + */ + private TButton closeButton; + + /** + * The X position for the buttons. + */ + private int buttonOffset = 14; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Public constructor. + * + * @param application TApplication that manages this window + * @param topic the topic to start on + */ + public THelpWindow(final TApplication application, final String topic) { + this (application, application.helpFile.getTopic(topic)); + } + + /** + * Public constructor. + * + * @param application TApplication that manages this window + * @param topic the topic to start on + */ + public THelpWindow(final TApplication application, final Topic topic) { + super(application, i18n.getString("windowTitle"), + 1, 1, 78, 22, CENTERED | RESIZABLE); + + setMinimumWindowHeight(16); + setMinimumWindowWidth(30); + + helpText = new THelpText(this, topic, 1, 1, + getWidth() - buttonOffset - 4, getHeight() - 4); + + setHelpTopic(topic); + + // Buttons + previousButton = addButton(i18n.getString("previousButton"), + getWidth() - buttonOffset, 4, + new TAction() { + public void DO() { + if (application.helpTopics.size() > 1) { + Topic previous = application.helpTopics.remove( + application.helpTopics.size() - 2); + application.helpTopics.remove(application. + helpTopics.size() - 1); + setHelpTopic(previous); + } + } + }); + + contentsButton = addButton(i18n.getString("contentsButton"), + getWidth() - buttonOffset, 6, + new TAction() { + public void DO() { + setHelpTopic(application.helpFile.getTableOfContents()); + } + }); + + indexButton = addButton(i18n.getString("indexButton"), + getWidth() - buttonOffset, 8, + new TAction() { + public void DO() { + setHelpTopic(application.helpFile.getIndex()); + } + }); + + closeButton = addButton(i18n.getString("closeButton"), + getWidth() - buttonOffset, 10, + new TAction() { + public void DO() { + // Don't copy anything, just close the window. + THelpWindow.this.close(); + } + }); + + // Save this for last: make the close button default action. + activate(closeButton); + + } + + /** + * Public constructor. + * + * @param application TApplication that manages this window + */ + public THelpWindow(final TApplication application) { + this(application, HELP_HELP); + } + + // ------------------------------------------------------------------------ + // Event handlers --------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Handle window/screen resize events. + * + * @param event resize event + */ + @Override + public void onResize(final TResizeEvent event) { + if (event.getType() == TResizeEvent.Type.WIDGET) { + + previousButton.setX(getWidth() - buttonOffset); + contentsButton.setX(getWidth() - buttonOffset); + indexButton.setX(getWidth() - buttonOffset); + closeButton.setX(getWidth() - buttonOffset); + + helpText.setDimensions(1, 1, getWidth() - buttonOffset - 4, + getHeight() - 4); + helpText.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, + helpText.getWidth(), helpText.getHeight())); + + return; + } else { + super.onResize(event); + } + } + + // ------------------------------------------------------------------------ + // TWindow ---------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Retrieve the background color. + * + * @return the background color + */ + @Override + public final CellAttributes getBackground() { + return getTheme().getColor("thelpwindow.background"); + } + + /** + * Retrieve the border color. + * + * @return the border color + */ + @Override + public CellAttributes getBorder() { + if (inWindowMove) { + return getTheme().getColor("thelpwindow.windowmove"); + } + return getTheme().getColor("thelpwindow.background"); + } + + /** + * Retrieve the color used by the window movement/sizing controls. + * + * @return the color used by the zoom box, resize bar, and close box + */ + @Override + public CellAttributes getBorderControls() { + return getTheme().getColor("thelpwindow.border"); + } + + // ------------------------------------------------------------------------ + // THelpWindow ------------------------------------------------------------ + // ------------------------------------------------------------------------ + + /** + * Set the topic to display. + * + * @param topic the topic to display + */ + public void setHelpTopic(final String topic) { + setHelpTopic(getApplication().helpFile.getTopic(topic)); + } + + /** + * Set the topic to display. + * + * @param topic the topic to display + */ + private void setHelpTopic(final Topic topic) { + boolean separator = true; + if ((topic == getApplication().helpFile.getTableOfContents()) + || (topic == getApplication().helpFile.getIndex()) + ) { + separator = false; + } + + getApplication().helpTopics.add(topic); + helpText.setTopic(topic, separator); + } + +} diff --git a/src/jexer/THelpWindow.properties b/src/jexer/THelpWindow.properties new file mode 100644 index 00000000..2b25484c --- /dev/null +++ b/src/jexer/THelpWindow.properties @@ -0,0 +1,5 @@ +windowTitle=Help +previousButton=Pre&vious +contentsButton=Co&ntents +indexButton=\ &Index\ \ +closeButton=\ C&lose\ \ diff --git a/src/jexer/TList.java b/src/jexer/TList.java index 6b0a205b..12e0b8a3 100644 --- a/src/jexer/TList.java +++ b/src/jexer/TList.java @@ -398,6 +398,9 @@ public class TList extends TScrollableWidget { int topY = 0; for (int i = begin; i < strings.size(); i++) { String line = strings.get(i); + if (line == null) { + line = ""; + } if (getHorizontalValue() < line.length()) { line = line.substring(getHorizontalValue()); } else { diff --git a/src/jexer/TTerminalWindow.java b/src/jexer/TTerminalWindow.java index a22625e8..754b7a51 100644 --- a/src/jexer/TTerminalWindow.java +++ b/src/jexer/TTerminalWindow.java @@ -35,6 +35,7 @@ import jexer.event.TKeypressEvent; import jexer.event.TMenuEvent; import jexer.event.TMouseEvent; import jexer.event.TResizeEvent; +import static jexer.TCommand.*; import static jexer.TKeypress.*; /** @@ -141,7 +142,11 @@ public class TTerminalWindow extends TScrollableWindow { addShortcutKeys(); // Add shortcut text - newStatusBar(i18n.getString("statusBarRunning")); + TStatusBar statusBar = newStatusBar(i18n.getString("statusBarRunning")); + statusBar.addShortcutKeypress(kbF1, cmHelp, + i18n.getString("statusBarHelp")); + statusBar.addShortcutKeypress(kbF10, cmMenu, + i18n.getString("statusBarMenu")); // Spin it up terminal = new TTerminalWidget(this, 0, 0, command, new TAction() { @@ -193,7 +198,11 @@ public class TTerminalWindow extends TScrollableWindow { addShortcutKeys(); // Add shortcut text - newStatusBar(i18n.getString("statusBarRunning")); + TStatusBar statusBar = newStatusBar(i18n.getString("statusBarRunning")); + statusBar.addShortcutKeypress(kbF1, cmHelp, + i18n.getString("statusBarHelp")); + statusBar.addShortcutKeypress(kbF10, cmMenu, + i18n.getString("statusBarMenu")); // Spin it up terminal = new TTerminalWidget(this, 0, 0, new TAction() { @@ -261,7 +270,10 @@ public class TTerminalWindow extends TScrollableWindow { */ @Override public void onKeypress(final TKeypressEvent keypress) { - if ((terminal != null) && (terminal.isReading())) { + if ((terminal != null) + && (terminal.isReading()) + && (!inKeyboardResize) + ) { terminal.onKeypress(keypress); } else { super.onKeypress(keypress); @@ -330,6 +342,16 @@ public class TTerminalWindow extends TScrollableWindow { } } + /** + * Get this window's help topic to load. + * + * @return the topic name + */ + @Override + public String getHelpTopic() { + return "Terminal Window"; + } + // ------------------------------------------------------------------------ // TTerminalWindow -------------------------------------------------------- // ------------------------------------------------------------------------ diff --git a/src/jexer/TTerminalWindow.properties b/src/jexer/TTerminalWindow.properties index ed22f492..44a19f68 100644 --- a/src/jexer/TTerminalWindow.properties +++ b/src/jexer/TTerminalWindow.properties @@ -1,2 +1,4 @@ windowTitle=Terminal statusBarRunning=Terminal session executing... +statusBarHelp=Help +statusBarMenu=Menu diff --git a/src/jexer/TText.java b/src/jexer/TText.java index 22bc4b89..f6d7febc 100644 --- a/src/jexer/TText.java +++ b/src/jexer/TText.java @@ -29,7 +29,7 @@ package jexer; import java.util.Arrays; -import java.util.LinkedList; +import java.util.ArrayList; import java.util.List; import jexer.bits.CellAttributes; @@ -162,7 +162,7 @@ public class TText extends TScrollableWidget { this.text = text; this.colorKey = colorKey; - lines = new LinkedList(); + lines = new ArrayList(); vScroller = new TVScroller(this, getWidth() - 1, 0, Math.max(1, getHeight() - 1)); @@ -403,7 +403,7 @@ public class TText extends TScrollableWidget { /** * Set justification. * - * @param justification LEFT, CENTER, RIGHT, or FULL + * @param justification NONE, LEFT, CENTER, RIGHT, or FULL */ public void setJustification(final Justification justification) { this.justification = justification; @@ -442,4 +442,12 @@ public class TText extends TScrollableWidget { reflowData(); } + /** + * Un-justify the text. + */ + public void unJustify() { + justification = Justification.NONE; + reflowData(); + } + } diff --git a/src/jexer/TWindow.java b/src/jexer/TWindow.java index ae3b85dd..4d14d0ee 100644 --- a/src/jexer/TWindow.java +++ b/src/jexer/TWindow.java @@ -199,6 +199,11 @@ public class TWindow extends TWidget { */ private boolean hideMouse = false; + /** + * The help topic for this window. + */ + protected String helpTopic = "Help"; + // ------------------------------------------------------------------------ // Constructors ----------------------------------------------------------- // ------------------------------------------------------------------------ @@ -1433,6 +1438,15 @@ public class TWindow extends TWidget { this.hideMouse = hideMouse; } + /** + * Get this window's help topic to load. + * + * @return the topic name + */ + public String getHelpTopic() { + return helpTopic; + } + /** * Generate a human-readable string for this window. * @@ -1440,8 +1454,9 @@ public class TWindow extends TWidget { */ @Override public String toString() { - return String.format("%s(%8x) \'%s\' position (%d, %d) geometry %dx%d" + - " hidden %s modal %s", getClass().getName(), hashCode(), title, + return String.format("%s(%8x) \'%s\' Z %d position (%d, %d) " + + "geometry %dx%d hidden %s modal %s", + getClass().getName(), hashCode(), title, getZ(), getX(), getY(), getWidth(), getHeight(), hidden, isModal()); } diff --git a/src/jexer/bits/ColorTheme.java b/src/jexer/bits/ColorTheme.java index 99eaa522..3efce633 100644 --- a/src/jexer/bits/ColorTheme.java +++ b/src/jexer/bits/ColorTheme.java @@ -681,6 +681,48 @@ public class ColorTheme { color.setBold(false); colors.put("tsplitpane", color); + // THelpWindow border - during window movement + color = new CellAttributes(); + color.setForeColor(Color.GREEN); + color.setBackColor(Color.CYAN); + color.setBold(true); + colors.put("thelpwindow.windowmove", color); + + // THelpWindow border + color = new CellAttributes(); + color.setForeColor(Color.GREEN); + color.setBackColor(Color.CYAN); + color.setBold(true); + colors.put("thelpwindow.border", color); + + // THelpWindow background + color = new CellAttributes(); + color.setForeColor(Color.WHITE); + color.setBackColor(Color.CYAN); + color.setBold(true); + colors.put("thelpwindow.background", color); + + // THelpWindow text + color = new CellAttributes(); + color.setForeColor(Color.WHITE); + color.setBackColor(Color.BLUE); + color.setBold(false); + colors.put("thelpwindow.text", color); + + // THelpWindow link + color = new CellAttributes(); + color.setForeColor(Color.YELLOW); + color.setBackColor(Color.BLUE); + color.setBold(true); + colors.put("thelpwindow.link", color); + + // THelpWindow link - active + color = new CellAttributes(); + color.setForeColor(Color.YELLOW); + color.setBackColor(Color.CYAN); + color.setBold(true); + colors.put("thelpwindow.link.active", color); + } /** diff --git a/src/jexer/bits/StringUtils.java b/src/jexer/bits/StringUtils.java index 36f7b4e9..d33f71f4 100644 --- a/src/jexer/bits/StringUtils.java +++ b/src/jexer/bits/StringUtils.java @@ -472,6 +472,10 @@ public class StringUtils { * @return the number of text cell columns required to display this string */ public static int width(final String str) { + if (str == null) { + return 0; + } + int n = 0; for (int i = 0; i < str.length();) { int ch = str.codePointAt(i); diff --git a/src/jexer/help/HelpFile.java b/src/jexer/help/HelpFile.java new file mode 100644 index 00000000..7a6f49e5 --- /dev/null +++ b/src/jexer/help/HelpFile.java @@ -0,0 +1,381 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.help; + +import java.io.InputStream; +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.ResourceBundle; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.ParserConfigurationException; +import org.xml.sax.SAXException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * A HelpFile is a collection of Topics with a table of contents and index of + * relevant terms. + */ +public class HelpFile { + + /** + * Translated strings. + */ + private static final ResourceBundle i18n = ResourceBundle.getBundle(HelpFile.class.getName()); + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The XML factory. + */ + private static DocumentBuilder domBuilder; + + /** + * The map of topics by title. + */ + private HashMap topicsByTitle; + + /** + * The map of topics by index key term. + */ + private HashMap topicsByTerm; + + /** + * The special "table of contents" topic. + */ + private Topic tableOfContents; + + /** + * The special "index" topic. + */ + private Topic index; + + /** + * The name of this help file. + */ + private String name = ""; + + /** + * The version of this help file. + */ + private String version = ""; + + /** + * The help file author. + */ + private String author = ""; + + /** + * The help file copyright/written by date. + */ + private String date = ""; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + // ------------------------------------------------------------------------ + // HelpFile --------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Load a help file from an input stream. + * + * @param input the input strem + * @throws IOException if an I/O error occurs + * @throws ParserConfigurationException if no XML parser is available + * @throws SAXException if XML parsing fails + */ + public void load(final InputStream input) throws IOException, + ParserConfigurationException, SAXException { + + topicsByTitle = new HashMap(); + topicsByTerm = new HashMap(); + + try { + loadTopics(input); + } finally { + // Always generate the TOC and Index from what was read. + generateTableOfContents(); + generateIndex(); + } + } + + /** + * Get a topic by title. + * + * @param title the title for the topic + * @return the topic, or the "not found" topic if title is not found + */ + public Topic getTopic(final String title) { + Topic topic = topicsByTitle.get(title); + if (topic == null) { + return Topic.NOT_FOUND; + } + return topic; + } + + /** + * Get the special "search results" topic. + * + * @param searchString a regular expression search string + * @return an index topic containing topics with text that matches the + * search string + */ + public Topic getSearchResults(final String searchString) { + List allTopics = new ArrayList(); + allTopics.addAll(topicsByTitle.values()); + Collections.sort(allTopics); + + List results = new ArrayList(); + Pattern pattern = Pattern.compile(searchString); + Pattern patternLower = Pattern.compile(searchString.toLowerCase()); + + for (Topic topic: allTopics) { + Matcher match = pattern.matcher(topic.getText().toLowerCase()); + if (match.find()) { + results.add(topic); + continue; + } + match = pattern.matcher(topic.getTitle().toLowerCase()); + if (match.find()) { + results.add(topic); + continue; + } + match = patternLower.matcher(topic.getText().toLowerCase()); + if (match.find()) { + results.add(topic); + continue; + } + match = patternLower.matcher(topic.getTitle().toLowerCase()); + if (match.find()) { + results.add(topic); + continue; + } + } + + StringBuilder text = new StringBuilder(); + int wordIndex = 0; + List links = new ArrayList(); + for (Topic topic: results) { + text.append(topic.getTitle()); + text.append("\n\n"); + + Link link = new Link(topic.getTitle(), topic.getTitle(), wordIndex); + wordIndex += link.getWordCount(); + links.add(link); + } + + return new Topic(MessageFormat.format(i18n.getString("searchResults"), + searchString), text.toString(), links); + } + + /** + * Get the special "table of contents" topic. + * + * @return the table of contents topic + */ + public Topic getTableOfContents() { + return tableOfContents; + } + + /** + * Get the special "index" topic. + * + * @return the index topic + */ + public Topic getIndex() { + return index; + } + + /** + * Generate the table of contents topic. + */ + private void generateTableOfContents() { + List allTopics = new ArrayList(); + allTopics.addAll(topicsByTitle.values()); + Collections.sort(allTopics); + + StringBuilder text = new StringBuilder(); + int wordIndex = 0; + List links = new ArrayList(); + for (Topic topic: allTopics) { + text.append(topic.getTitle()); + text.append("\n\n"); + + Link link = new Link(topic.getTitle(), topic.getTitle(), wordIndex); + wordIndex += link.getWordCount(); + links.add(link); + } + + tableOfContents = new Topic(i18n.getString("tableOfContents"), + text.toString(), links); + } + + /** + * Generate the index topic. + */ + private void generateIndex() { + List allTopics = new ArrayList(); + allTopics.addAll(topicsByTitle.values()); + + HashMap> allKeys; + allKeys = new HashMap>(); + for (Topic topic: allTopics) { + for (String key: topic.getIndexKeys()) { + key = key.toLowerCase(); + ArrayList topics = allKeys.get(key); + if (topics == null) { + topics = new ArrayList(); + allKeys.put(key, topics); + } + topics.add(topic); + } + } + List keys = new ArrayList(); + keys.addAll(allKeys.keySet()); + Collections.sort(keys); + + StringBuilder text = new StringBuilder(); + int wordIndex = 0; + List links = new ArrayList(); + + for (String key: keys) { + List topics = allKeys.get(key); + assert (topics != null); + for (Topic topic: topics) { + String line = String.format("%15s %15s", key, topic.getTitle()); + text.append(line); + text.append("\n\n"); + + wordIndex += key.split("\\s+").length; + Link link = new Link(topic.getTitle(), topic.getTitle(), wordIndex); + wordIndex += link.getWordCount(); + links.add(link); + } + } + + index = new Topic(i18n.getString("index"), text.toString(), links); + } + + /** + * Load topics from a help file into the topics pool. + * + * @param input the input strem + * @throws IOException if an I/O error occurs + * @throws ParserConfigurationException if no XML parser is available + * @throws SAXException if XML parsing fails + */ + private void loadTopics(final InputStream input) throws IOException, + ParserConfigurationException, SAXException { + + if (domBuilder == null) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory. + newInstance(); + domBuilder = dbFactory.newDocumentBuilder(); + } + Document doc = domBuilder.parse(input); + + // Get the document's root XML node + Node root = doc.getChildNodes().item(0); + NodeList level1 = root.getChildNodes(); + for (int i = 0; i < level1.getLength(); i++) { + Node node = level1.item(i); + String name = node.getNodeName(); + String value = node.getTextContent(); + + if (name.equals("name")) { + this.name = value; + } + if (name.equals("version")) { + this.version = value; + } + if (name.equals("author")) { + this.author = value; + } + if (name.equals("date")) { + this.date = value; + } + if (name.equals("topics")) { + NodeList topics = node.getChildNodes(); + for (int j = 0; j < topics.getLength(); j++) { + Node topic = topics.item(j); + addTopic(topic); + } + } + } + } + + /** + * Add a topic to this help file. + * + * @param xmlNode the topic XML node + * @throws IOException if a java.io operation throws + */ + private void addTopic(final Node xmlNode) throws IOException { + String title = ""; + String text = ""; + + NamedNodeMap attributes = xmlNode.getAttributes(); + if (attributes != null) { + for (int i = 0; i < attributes.getLength(); i++) { + Node attr = attributes.item(i); + if (attr.getNodeName().equals("title")) { + title = attr.getNodeValue().trim(); + } + } + } + NodeList level2 = xmlNode.getChildNodes(); + for (int i = 0; i < level2.getLength(); i++) { + Node node = level2.item(i); + String nodeName = node.getNodeName(); + String nodeValue = node.getTextContent(); + if (nodeName.equals("text")) { + text = nodeValue.trim(); + } + } + if (title.length() > 0) { + Topic topic = new Topic(title, text); + topicsByTitle.put(title, topic); + } + } + +} diff --git a/src/jexer/help/HelpFile.properties b/src/jexer/help/HelpFile.properties new file mode 100644 index 00000000..803961b9 --- /dev/null +++ b/src/jexer/help/HelpFile.properties @@ -0,0 +1,3 @@ +tableOfContents=Table Of Contents +index=Index +searchResults=Search Results - {0} diff --git a/src/jexer/help/Link.java b/src/jexer/help/Link.java new file mode 100644 index 00000000..665381cb --- /dev/null +++ b/src/jexer/help/Link.java @@ -0,0 +1,135 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.help; + +import java.util.HashSet; +import java.util.Set; +import java.util.ResourceBundle; + +/** + * A Link is a section of text with a reference to a Topic. + */ +public class Link { + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The topic id that this link points to. + */ + private String topic; + + /** + * The text inside the link tag. + */ + private String text; + + /** + * The number of words in this link. + */ + private int wordCount; + + /** + * The word number (from the beginning of topic text) that corresponds to + * the first word of this link. + */ + private int index; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Public constructor. + * + * @param topic the topic to point to + * @param text the text inside the link tag + * @param index the word count index + */ + public Link(final String topic, final String text, final int index) { + this.topic = topic; + this.text = text; + this.index = index; + this.wordCount = text.split("\\s+").length; + } + + // ------------------------------------------------------------------------ + // Link ------------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Get the topic. + * + * @return the topic + */ + public String getTopic() { + return topic; + } + + /** + * Get the link text. + * + * @return the text inside the link tag + */ + public String getText() { + return text; + } + + /** + * Get the word index for this link. + * + * @return the word number (from the beginning of topic text) that + * corresponds to the first word of this link + */ + public int getIndex() { + return index; + } + + /** + * Get the number of words in this link. + * + * @return the number of words in this link + */ + public int getWordCount() { + return wordCount; + } + + /** + * Generate a human-readable string for this widget. + * + * @return a human-readable string + */ + @Override + public String toString() { + return String.format("%s(%8x) topic %s link text %s word # %d count %d", + getClass().getName(), hashCode(), topic, text, index, wordCount); + } + +} diff --git a/src/jexer/help/THelpText.java b/src/jexer/help/THelpText.java new file mode 100644 index 00000000..2e0afcfb --- /dev/null +++ b/src/jexer/help/THelpText.java @@ -0,0 +1,389 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.help; + +import java.util.ArrayList; +import java.util.List; + +import jexer.THelpWindow; +import jexer.TScrollableWidget; +import jexer.TVScroller; +import jexer.TWidget; +import jexer.bits.CellAttributes; +import jexer.bits.StringUtils; +import jexer.event.TKeypressEvent; +import jexer.event.TMouseEvent; +import static jexer.TKeypress.*; + +/** + * THelpText displays help text with clickable links in a scrollable text + * area. It reflows automatically on resize. + */ +public class THelpText extends TScrollableWidget { + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The number of lines to scroll on mouse wheel up/down. + */ + private static final int wheelScrollSize = 3; + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The paragraphs in this text box. + */ + private List paragraphs; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Public constructor. + * + * @param parent parent widget + * @param topic the topic to display + * @param x column relative to parent + * @param y row relative to parent + * @param width width of text area + * @param height height of text area + */ + public THelpText(final THelpWindow parent, final Topic topic, final int x, + final int y, final int width, final int height) { + + // Set parent and window + super(parent, x, y, width, height); + + vScroller = new TVScroller(this, getWidth() - 1, 0, + Math.max(1, getHeight())); + + setTopic(topic); + } + + // ------------------------------------------------------------------------ + // TScrollableWidget ------------------------------------------------------ + // ------------------------------------------------------------------------ + + /** + * Override TWidget's width: we need to set child widget widths. + * + * @param width new widget width + */ + @Override + public void setWidth(final int width) { + super.setWidth(width); + if (hScroller != null) { + hScroller.setWidth(getWidth() - 1); + } + if (vScroller != null) { + vScroller.setX(getWidth() - 1); + } + } + + /** + * Override TWidget's height: we need to set child widget heights. + * time. + * + * @param height new widget height + */ + @Override + public void setHeight(final int height) { + super.setHeight(height); + if (hScroller != null) { + hScroller.setY(getHeight() - 1); + } + if (vScroller != null) { + vScroller.setHeight(Math.max(1, getHeight())); + } + } + + /** + * Handle mouse press events. + * + * @param mouse mouse button press event + */ + @Override + public void onMouseDown(final TMouseEvent mouse) { + // Pass to children + super.onMouseDown(mouse); + + if (mouse.isMouseWheelUp()) { + for (int i = 0; i < wheelScrollSize; i++) { + vScroller.decrement(); + } + reflowData(); + return; + } + if (mouse.isMouseWheelDown()) { + for (int i = 0; i < wheelScrollSize; i++) { + vScroller.increment(); + } + reflowData(); + return; + } + + // User clicked on a paragraph, update the scrollbar accordingly. + for (int i = 0; i < paragraphs.size(); i++) { + if (paragraphs.get(i).isActive()) { + setVerticalValue(i); + return; + } + } + } + + /** + * Handle keystrokes. + * + * @param keypress keystroke event + */ + @Override + public void onKeypress(final TKeypressEvent keypress) { + if (keypress.equals(kbTab)) { + getParent().switchWidget(true); + } else if (keypress.equals(kbShiftTab)) { + getParent().switchWidget(false); + } else if (keypress.equals(kbUp)) { + if (!paragraphs.get(getVerticalValue()).up()) { + vScroller.decrement(); + reflowData(); + } + } else if (keypress.equals(kbDown)) { + if (!paragraphs.get(getVerticalValue()).down()) { + vScroller.increment(); + reflowData(); + } + } else if (keypress.equals(kbPgUp)) { + vScroller.bigDecrement(); + reflowData(); + } else if (keypress.equals(kbPgDn)) { + vScroller.bigIncrement(); + reflowData(); + } else if (keypress.equals(kbHome)) { + vScroller.toTop(); + reflowData(); + } else if (keypress.equals(kbEnd)) { + vScroller.toBottom(); + reflowData(); + } else { + // Pass other keys on + super.onKeypress(keypress); + } + } + + /** + * Place the scrollbars on the edge of this widget, and adjust bigChange + * to match the new size. This is called by onResize(). + */ + protected void placeScrollbars() { + if (hScroller != null) { + hScroller.setY(getHeight() - 1); + hScroller.setWidth(getWidth() - 1); + hScroller.setBigChange(getWidth() - 1); + } + if (vScroller != null) { + vScroller.setX(getWidth() - 1); + vScroller.setHeight(getHeight()); + vScroller.setBigChange(getHeight()); + } + } + + /** + * Resize text and scrollbars for a new width/height. + */ + @Override + public void reflowData() { + for (TParagraph paragraph: paragraphs) { + paragraph.setWidth(getWidth() - 1); + paragraph.reflowData(); + } + + int top = getVerticalValue(); + int paragraphsHeight = 0; + for (TParagraph paragraph: paragraphs) { + paragraphsHeight += paragraph.getHeight(); + } + if (paragraphsHeight <= getHeight()) { + // All paragraphs fit in the window. + int y = 0; + for (int i = 0; i < paragraphs.size(); i++) { + paragraphs.get(i).setEnabled(true); + paragraphs.get(i).setVisible(true); + paragraphs.get(i).setY(y); + y += paragraphs.get(i).getHeight(); + } + activate(paragraphs.get(getVerticalValue())); + return; + } + + /* + * Some paragraphs will not fit in the window. Find the number of + * rows needed to display from the current vertical position to the + * end: + * + * - If this meets or exceeds the available height, then draw from + * the vertical position to the number of visible rows. + * + * - If this is less than the available height, back up until + * meeting/exceeding the height, and draw from there to the end. + * + */ + int rowsNeeded = 0; + for (int i = getVerticalValue(); i <= getBottomValue(); i++) { + rowsNeeded += paragraphs.get(i).getHeight(); + } + while (rowsNeeded < getHeight()) { + // Decrease top until we meet/exceed the visible display. + if (top == getTopValue()) { + break; + } + top--; + rowsNeeded += paragraphs.get(top).getHeight(); + } + + // All set, now disable all paragraphs except the visible ones. + for (TParagraph paragraph: paragraphs) { + paragraph.setEnabled(false); + paragraph.setVisible(false); + paragraph.setY(-1); + } + int y = 0; + for (int i = top; (i <= getBottomValue()) && (y < getHeight()); i++) { + paragraphs.get(i).setEnabled(true); + paragraphs.get(i).setVisible(true); + paragraphs.get(i).setY(y); + y += paragraphs.get(i).getHeight(); + } + activate(paragraphs.get(getVerticalValue())); + } + + /** + * Draw the text box. + */ + @Override + public void draw() { + // Setup my color + CellAttributes color = getTheme().getColor("thelpwindow.text"); + for (int y = 0; y < getHeight(); y++) { + hLineXY(0, y, getWidth(), ' ', color); + } + } + + // ------------------------------------------------------------------------ + // THelpText -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Set the topic. + * + * @param topic new topic to display + */ + public void setTopic(final Topic topic) { + setTopic(topic, true); + } + + /** + * Set the topic. + * + * @param topic new topic to display + * @param separator if true, separate paragraphs + */ + public void setTopic(final Topic topic, final boolean separator) { + + if (paragraphs != null) { + getChildren().removeAll(paragraphs); + } + paragraphs = new ArrayList(); + + // Add title paragraph at top. We explicitly set the separator to + // false to achieve the underscore effect. + List title = new ArrayList(); + title.add(new TWord(topic.getTitle(), null)); + TParagraph titleParagraph = new TParagraph(this, title); + titleParagraph.separator = false; + paragraphs.add(titleParagraph); + title = new ArrayList(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < topic.getTitle().length(); i++) { + sb.append('\u2580'); + } + title.add(new TWord(sb.toString(), null)); + titleParagraph = new TParagraph(this, title); + paragraphs.add(titleParagraph); + + // Now add the actual text as paragraphs. + int wordIndex = 0; + + // Break up text into paragraphs + String [] blocks = topic.getText().split("\n\n"); + for (String block: blocks) { + List words = new ArrayList(); + String [] lines = block.split("\n"); + for (String line: lines) { + line = line.trim(); + // System.err.println("line: " + line); + String [] wordTokens = line.split("\\s+"); + for (int i = 0; i < wordTokens.length; i++) { + String wordStr = wordTokens[i].trim(); + Link wordLink = null; + for (Link link: topic.getLinks()) { + if ((i + wordIndex >= link.getIndex()) + && (i + wordIndex < link.getIndex() + link.getWordCount()) + ) { + // This word is part of a link. + wordLink = link; + wordStr = link.getText(); + i += link.getWordCount() - 1; + break; + } + } + TWord word = new TWord(wordStr, wordLink); + /* + System.err.println("add word at " + (i + wordIndex) + " : " + + wordStr + " " + wordLink); + */ + words.add(word); + } // for (int i = 0; i < words.length; i++) + wordIndex += wordTokens.length; + } // for (String line: lines) + TParagraph paragraph = new TParagraph(this, words); + paragraph.separator = separator; + paragraphs.add(paragraph); + } // for (String block: blocks) + + setBottomValue(paragraphs.size() - 1); + setVerticalValue(0); + reflowData(); + } + +} diff --git a/src/jexer/help/TParagraph.java b/src/jexer/help/TParagraph.java new file mode 100644 index 00000000..04559da1 --- /dev/null +++ b/src/jexer/help/TParagraph.java @@ -0,0 +1,175 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.help; + +import java.util.List; + +import jexer.TWidget; + +/** + * TParagraph contains a reflowable collection of TWords, some of which are + * clickable links. + */ +public class TParagraph extends TWidget { + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Topic text and links converted to words. + */ + private List words; + + /** + * If true, add one row to height as a paragraph separator. Note package + * private access. + */ + boolean separator = true; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Public constructor. + * + * @param parent parent widget + * @param words the pieces of the paragraph to display + */ + public TParagraph(final THelpText parent, final List words) { + + // Set parent and window + super(parent, 0, 0, parent.getWidth() - 1, 1); + + this.words = words; + for (TWord word: words) { + word.setParent(this, false); + } + + reflowData(); + } + + // ------------------------------------------------------------------------ + // TWidget ---------------------------------------------------------------- + // ------------------------------------------------------------------------ + + // ------------------------------------------------------------------------ + // TParagraph ------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Reposition the words in this paragraph to reflect the new width, and + * set the paragraph height. + */ + public void reflowData() { + int x = 0; + int y = 0; + for (TWord word: words) { + if (x + word.getWidth() >= getWidth()) { + x = 0; + y++; + } + word.setX(x); + word.setY(y); + x += word.getWidth() + 1; + } + if (separator) { + setHeight(y + 2); + } else { + setHeight(y + 1); + } + } + + /** + * Try to select a previous link. + * + * @return true if there was a previous link in this paragraph to select + */ + public boolean up() { + if (words.size() == 0) { + return false; + } + if (getActiveChild() == this) { + // No selectable links + return false; + } + TWord firstWord = null; + TWord lastWord = null; + for (TWord word: words) { + if (word.isEnabled()) { + if (firstWord == null) { + firstWord = word; + } + lastWord = word; + } + } + if (getActiveChild() == firstWord) { + return false; + } + switchWidget(false); + return true; + } + + /** + * Try to select a next link. + * + * @return true if there was a next link in this paragraph to select + */ + public boolean down() { + if (words.size() == 0) { + return false; + } + if (getActiveChild() == this) { + // No selectable links + return false; + } + TWord firstWord = null; + TWord lastWord = null; + for (TWord word: words) { + if (word.isEnabled()) { + if (firstWord == null) { + firstWord = word; + } + lastWord = word; + } + } + if (getActiveChild() == lastWord) { + return false; + } + switchWidget(true); + return true; + } + + +} diff --git a/src/jexer/help/TWord.java b/src/jexer/help/TWord.java new file mode 100644 index 00000000..d46a22e0 --- /dev/null +++ b/src/jexer/help/TWord.java @@ -0,0 +1,143 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.help; + +import jexer.THelpWindow; +import jexer.TWidget; +import jexer.bits.CellAttributes; +import jexer.bits.StringUtils; +import jexer.event.TKeypressEvent; +import jexer.event.TMouseEvent; +import static jexer.TKeypress.*; + +/** + * TWord contains either a string to display or a clickable link. + */ +public class TWord extends TWidget { + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The word(s) to display. + */ + private String words; + + /** + * Link to another Topic. + */ + private Link link; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Public constructor. + * + * @param words the words to display + * @param link link to other topic, or null + */ + public TWord(final String words, final Link link) { + + // TWord is created by THelpText before the TParagraph is belongs to + // is created, so pass null as parent for now. + super(null, 0, 0, StringUtils.width(words), 1); + + this.words = words; + this.link = link; + + // Don't make text-only words "active". + if (link == null) { + setEnabled(false); + } + } + + // ------------------------------------------------------------------------ + // Event handlers --------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Handle mouse press events. + * + * @param mouse mouse button press event + */ + @Override + public void onMouseDown(final TMouseEvent mouse) { + if (mouse.isMouse1()) { + if (link != null) { + ((THelpWindow) getWindow()).setHelpTopic(link.getTopic()); + } + } + } + + /** + * Handle keystrokes. + * + * @param keypress keystroke event + */ + @Override + public void onKeypress(final TKeypressEvent keypress) { + if (keypress.equals(kbEnter)) { + if (link != null) { + ((THelpWindow) getWindow()).setHelpTopic(link.getTopic()); + } + } + } + + // ------------------------------------------------------------------------ + // TWidget ---------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Draw the words. + */ + @Override + public void draw() { + CellAttributes color = getTheme().getColor("thelpwindow.text"); + if (link != null) { + if (isAbsoluteActive()) { + color = getTheme().getColor("thelpwindow.link.active"); + } else { + color = getTheme().getColor("thelpwindow.link"); + } + } + putStringXY(0, 0, words, color); + } + + // ------------------------------------------------------------------------ + // TWord ------------------------------------------------------------------ + // ------------------------------------------------------------------------ + +} diff --git a/src/jexer/help/Topic.java b/src/jexer/help/Topic.java new file mode 100644 index 00000000..8c0bc712 --- /dev/null +++ b/src/jexer/help/Topic.java @@ -0,0 +1,339 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.help; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.ResourceBundle; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A Topic is a page of help text with a title and possibly links to other + * Topics. + */ +public class Topic implements Comparable { + + /** + * Translated strings. + */ + private static final ResourceBundle i18n = ResourceBundle.getBundle(Topic.class.getName()); + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The "not found" topic to display when a key or index term does not + * have an associated topic. Note package private access. + */ + static Topic NOT_FOUND = null; + + /** + * The regex for identifying index tags. + */ + private static final String INDEX_REGEX_STR = "\\#\\{([^\\}]*)\\}"; + + /** + * The regex for identifying link tags. + */ + private static final String LINK_REGEX_STR = "\\[([^\\]]*)\\]\\(([^\\)]*)\\)"; + + /** + * The regex for identifying words. + */ + private static final String WORD_REGEX_STR = "[ \\t]+"; + + /** + * The index match regex. + */ + private static Pattern INDEX_REGEX; + + /** + * The link match regex. + */ + private static Pattern LINK_REGEX; + + /** + * The word match regex. + */ + private static Pattern WORD_REGEX; + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The title for this topic. + */ + private String title; + + /** + * The text for this topic. + */ + private String text; + + /** + * The index keys in this topic. + */ + private Set indexKeys = new HashSet(); + + /** + * The links in this topic. + */ + private List links = new ArrayList(); + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Static constructor. + */ + static { + try { + INDEX_REGEX = Pattern.compile(INDEX_REGEX_STR); + LINK_REGEX = Pattern.compile(LINK_REGEX_STR); + WORD_REGEX = Pattern.compile(WORD_REGEX_STR); + + NOT_FOUND = new Topic(i18n.getString("topicNotFoundTitle"), + i18n.getString("topicNotFoundText")); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Public constructor. + * + * @param title the topic title + * @param text the topic text + */ + public Topic(final String title, final String text) { + this.title = title; + processText(text); + } + + /** + * Package private constructor. + * + * @param title the topic title + * @param text the topic text + * @param links links to add after processing text + */ + Topic(final String title, final String text, final List links) { + this.title = title; + processText(text); + this.links.addAll(links); + } + + // ------------------------------------------------------------------------ + // Topic ------------------------------------------------------------------ + // ------------------------------------------------------------------------ + + /** + * Get the topic title. + * + * @return the title + */ + public String getTitle() { + return title; + } + + /** + * Get the topic text. + * + * @return the text + */ + public String getText() { + return text; + } + + /** + * Get the index keys. + * + * @return the keys + */ + public Set getIndexKeys() { + return indexKeys; + } + + /** + * Get the links. + * + * @return the links + */ + public List getLinks() { + return links; + } + + /** + * Comparison operator. + * + * @param that another Topic instance + * @return comparison by topic title + */ + public int compareTo(final Topic that) { + return title.compareTo(that.title); + } + + /** + * Generate a human-readable string for this widget. + * + * @return a human-readable string + */ + @Override + public String toString() { + return String.format("%s(%8x) topic %s text %s links %s indexKeys %s", + getClass().getName(), hashCode(), title, text, links, indexKeys); + } + + /** + * Process a string through the regexes, building up the indexes and + * links. + * + * @param text the text to process + */ + private void processText(final String text) { + StringBuilder sb = new StringBuilder(); + String [] lines = text.split("\n"); + int wordIndex = 0; + for (String line: lines) { + line = line.trim(); + + String cleanLine = ""; + + // System.err.println("LINE " + wordIndex + " : '" + line + "'"); + + Matcher index = INDEX_REGEX.matcher(line); + int start = 0; + while (index.find()) { + cleanLine += line.substring(start, index.start()); + String key = index.group(1); + cleanLine += key; + start = index.end(); + // System.err.println("ADD KEY: " + key); + indexKeys.add(key); + } + cleanLine += line.substring(start); + + line = cleanLine; + cleanLine = ""; + + /* + System.err.println("line after removing #{index} tags: " + + wordIndex + " '" + line + "'"); + */ + + Matcher link = LINK_REGEX.matcher(line); + start = 0; + + boolean hasLink = link.find(); + + // System.err.println("hasLink " + hasLink); + + while (true) { + + if (hasLink == false) { + cleanLine += line.substring(start); + + String remaining = line.substring(start).trim(); + Matcher word = WORD_REGEX.matcher(remaining); + while (word.find()) { + // System.err.println("word.find() true"); + wordIndex++; + } + if (remaining.length() > 0) { + // The last word on the line. + wordIndex++; + } + break; + } + + assert (hasLink == true); + + int linkWordIndex = link.start(); + int cleanLineStart = cleanLine.length(); + cleanLine += line.substring(start, linkWordIndex); + String linkText = link.group(1); + String topic = link.group(2); + cleanLine += linkText; + start = link.end(); + + // Increment wordIndex until we reach the first word of + // the link text. + Matcher word = WORD_REGEX.matcher(cleanLine. + substring(cleanLineStart)); + while (word.find()) { + if (word.end() <= linkWordIndex) { + wordIndex++; + } else { + // We have found the word that matches the first + // word of link text, bail out. + break; + } + } + /* + System.err.println("ADD LINK --> " + topic + ": '" + + linkText + "' word index " + wordIndex); + */ + links.add(new Link(topic, linkText, wordIndex)); + + // The rest of the words in the link text. + while (word.find()) { + wordIndex++; + } + // The final word after the last whitespace. + wordIndex++; + + hasLink = link.find(); + if (hasLink) { + wordIndex += 3; + } + } + + + /* + System.err.println("line after removing [link](...) tags: '" + + cleanLine + "'"); + */ + + // Append the entire line. + sb.append(cleanLine); + sb.append("\n"); + + this.text = sb.toString(); + + } // for (String line: lines) + + } + +} diff --git a/src/jexer/help/Topic.properties b/src/jexer/help/Topic.properties new file mode 100644 index 00000000..1c8de6f9 --- /dev/null +++ b/src/jexer/help/Topic.properties @@ -0,0 +1,2 @@ +topicNotFoundTitle=Topic Not Found +topicNotFoundText=The help topic was not found. diff --git a/src/jexer/help/package-info.java b/src/jexer/help/package-info.java new file mode 100644 index 00000000..409c370c --- /dev/null +++ b/src/jexer/help/package-info.java @@ -0,0 +1,33 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ + +/** + * Online help system. + */ +package jexer.help; -- 2.27.0