Merge commit '919bbc354cd2555eb0955be0ef2dcf338047d022'
authorNiki Roo <niki@nikiroo.be>
Mon, 11 May 2020 23:01:33 +0000 (01:01 +0200)
committerNiki Roo <niki@nikiroo.be>
Mon, 11 May 2020 23:01:33 +0000 (01:01 +0200)
473 files changed:
LICENSE [new file with mode: 0644]
Makefile.base [new file with mode: 0644]
README-fr.md [new file with mode: 0644]
README.md [new file with mode: 0644]
TODO.md [new file with mode: 0644]
VERSION [new file with mode: 0644]
changelog-fr.md [new file with mode: 0644]
changelog.md [new file with mode: 0644]
configure.sh [new file with mode: 0755]
derename.sh [new file with mode: 0755]
docs/android/android.md [new file with mode: 0644]
docs/android/screens/desc.jpg [new file with mode: 0755]
docs/android/screens/main_lib.jpg [new file with mode: 0755]
docs/android/screens/menu.jpg [new file with mode: 0755]
docs/android/screens/navigation.jpg [new file with mode: 0755]
docs/android/screens/options.jpg [new file with mode: 0755]
docs/android/screens/search.jpg [new file with mode: 0755]
docs/android/screens/viewer-image.jpg [new file with mode: 0755]
docs/android/screens/viewer-text.jpg [new file with mode: 0755]
docs/android/screens/viewer.jpg [new file with mode: 0755]
fanfix.sysv [new file with mode: 0755]
icons/fanfix-alt.png [new file with mode: 0644]
icons/fanfix.png [new file with mode: 0644]
icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png [new file with mode: 0644]
icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png [new file with mode: 0644]
icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png [new file with mode: 0644]
libs/JSON-java-20190722-sources.jar [new file with mode: 0644]
libs/jexer-0.0.4_README.md [new file with mode: 0644]
libs/jsoup-1.10.3-sources.jar [new file with mode: 0644]
libs/licenses/JSON-java-20190722_LICENSE.txt [new file with mode: 0644]
libs/licenses/jexer-0.0.4_LICENSE.txt [new file with mode: 0644]
libs/licenses/nanohttpd-2.3.1-LICENSE.md [new file with mode: 0644]
libs/licenses/unbescape-1.1.4_LICENSE.txt [new file with mode: 0644]
libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz [new file with mode: 0644]
libs/subtree.txt [new file with mode: 0755]
libs/unbescape-1.1.4-sources.jar [new file with mode: 0644]
libs/unbescape-1.1.4_ChangeLog.txt [new file with mode: 0644]
screenshots/README-fr.md [new file with mode: 0644]
screenshots/README.md [new file with mode: 0644]
screenshots/fanfix-1.0.0.png [new file with mode: 0644]
screenshots/fanfix-1.3.2.png [new file with mode: 0644]
src/be/nikiroo/fanfix/DataLoader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Instance.java [new file with mode: 0644]
src/be/nikiroo/fanfix/Main.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/Config.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/ConfigBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringId.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdGui.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/Target.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/UiConfig.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/UiConfigBundle.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/UiConfigBundleDesc.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_core.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_core_fr.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_gui.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/bundles/ui_description.properties [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Chapter.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/JsonIO.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/MetaData.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Paragraph.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/Story.java [new file with mode: 0644]
src/be/nikiroo/fanfix/data/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/BasicLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/CacheLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/LocalLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/MetaResultList.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/RemoteLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/RemoteLibraryException.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/RemoteLibraryServer.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/WebLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/WebLibraryServer.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/WebLibraryServerIndex.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/actual_size-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/actual_size-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/avicon.ico [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/back-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/back-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/favicon.ico [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_alternative.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_default.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_magic_book.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_pony_book.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_pony_library.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/index.post.html [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/index.pre.html [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/search-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/search-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/unknown-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/unknown-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/output/BasicOutput.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Cbz.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Epub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Html.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/InfoCover.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/InfoText.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/LaTeX.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Sysout.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/Text.java [new file with mode: 0644]
src/be/nikiroo/fanfix/output/epub.style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/output/html.style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/output/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/BasicReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/CliReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/TextOutput.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/BasicSearchable.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/Fanfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/MangaLel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/searchable/SearchableTag.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupport.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupportHelper.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupportImages.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupportPara.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Cbz.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/E621.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/EHentai.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Epub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Fanfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Fimfiction.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/FimfictionApi.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Html.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/InfoReader.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/InfoText.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/MangaHub.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/MangaLel.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/SupportType.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/Text.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/YiffStar.java [new file with mode: 0644]
src/be/nikiroo/fanfix/supported/package-info.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/BasicSupportDeprecatedTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/ConversionTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/LibraryTest.java [new file with mode: 0644]
src/be/nikiroo/fanfix/test/Test.java [new file with mode: 0644]
src/be/nikiroo/jexer/TBrowsableWidget.java [new file with mode: 0644]
src/be/nikiroo/jexer/TSizeConstraint.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTable.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableCellRenderer.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableCellRendererText.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableCellRendererWidget.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableColumn.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableLine.java [new file with mode: 0644]
src/be/nikiroo/jexer/TTableModel.java [new file with mode: 0644]
src/be/nikiroo/utils/Cache.java [moved from Cache.java with 100% similarity]
src/be/nikiroo/utils/CacheMemory.java [moved from CacheMemory.java with 100% similarity]
src/be/nikiroo/utils/CookieUtils.java [moved from CookieUtils.java with 100% similarity]
src/be/nikiroo/utils/CryptUtils.java [moved from CryptUtils.java with 100% similarity]
src/be/nikiroo/utils/Downloader.java [moved from Downloader.java with 100% similarity]
src/be/nikiroo/utils/HashUtils.java [moved from HashUtils.java with 100% similarity]
src/be/nikiroo/utils/IOUtils.java [moved from IOUtils.java with 100% similarity]
src/be/nikiroo/utils/Image.java [moved from Image.java with 100% similarity]
src/be/nikiroo/utils/ImageUtils.java [moved from ImageUtils.java with 100% similarity]
src/be/nikiroo/utils/MarkableFileInputStream.java [moved from MarkableFileInputStream.java with 100% similarity]
src/be/nikiroo/utils/NanoHTTPD.java [moved from NanoHTTPD.java with 100% similarity]
src/be/nikiroo/utils/Progress.java [moved from Progress.java with 100% similarity]
src/be/nikiroo/utils/Proxy.java [moved from Proxy.java with 100% similarity]
src/be/nikiroo/utils/StringJustifier.java [moved from StringJustifier.java with 100% similarity]
src/be/nikiroo/utils/StringUtils.java [moved from StringUtils.java with 100% similarity]
src/be/nikiroo/utils/TempFiles.java [moved from TempFiles.java with 100% similarity]
src/be/nikiroo/utils/TraceHandler.java [moved from TraceHandler.java with 100% similarity]
src/be/nikiroo/utils/Version.java [moved from Version.java with 100% similarity]
src/be/nikiroo/utils/VersionCheck.java [moved from VersionCheck.java with 100% similarity]
src/be/nikiroo/utils/android/ImageUtilsAndroid.class [moved from android/ImageUtilsAndroid.class with 100% similarity]
src/be/nikiroo/utils/android/ImageUtilsAndroid.java [moved from android/ImageUtilsAndroid.java with 100% similarity]
src/be/nikiroo/utils/android/test/TestAndroid.class [moved from android/test/TestAndroid.class with 100% similarity]
src/be/nikiroo/utils/android/test/TestAndroid.java [moved from android/test/TestAndroid.java with 100% similarity]
src/be/nikiroo/utils/main/bridge.java [moved from main/bridge.java with 100% similarity]
src/be/nikiroo/utils/main/img2aa.java [moved from main/img2aa.java with 100% similarity]
src/be/nikiroo/utils/main/justify.java [moved from main/justify.java with 100% similarity]
src/be/nikiroo/utils/resources/Bundle.java [moved from resources/Bundle.java with 100% similarity]
src/be/nikiroo/utils/resources/BundleHelper.java [moved from resources/BundleHelper.java with 100% similarity]
src/be/nikiroo/utils/resources/Bundles.java [moved from resources/Bundles.java with 100% similarity]
src/be/nikiroo/utils/resources/FixedResourceBundleControl.java [moved from resources/FixedResourceBundleControl.java with 100% similarity]
src/be/nikiroo/utils/resources/Meta.java [moved from resources/Meta.java with 100% similarity]
src/be/nikiroo/utils/resources/MetaInfo.java [moved from resources/MetaInfo.java with 100% similarity]
src/be/nikiroo/utils/resources/TransBundle.java [moved from resources/TransBundle.java with 100% similarity]
src/be/nikiroo/utils/resources/TransBundle_ResourceList.java [moved from resources/TransBundle_ResourceList.java with 100% similarity]
src/be/nikiroo/utils/resources/package-info.java [moved from resources/package-info.java with 100% similarity]
src/be/nikiroo/utils/serial/CustomSerializer.java [moved from serial/CustomSerializer.java with 100% similarity]
src/be/nikiroo/utils/serial/Exporter.java [moved from serial/Exporter.java with 100% similarity]
src/be/nikiroo/utils/serial/Importer.java [moved from serial/Importer.java with 100% similarity]
src/be/nikiroo/utils/serial/SerialUtils.java [moved from serial/SerialUtils.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectAction.java [moved from serial/server/ConnectAction.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionClient.java [moved from serial/server/ConnectActionClient.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java [moved from serial/server/ConnectActionClientObject.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionClientString.java [moved from serial/server/ConnectActionClientString.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionServer.java [moved from serial/server/ConnectActionServer.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java [moved from serial/server/ConnectActionServerObject.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ConnectActionServerString.java [moved from serial/server/ConnectActionServerString.java with 100% similarity]
src/be/nikiroo/utils/serial/server/Server.java [moved from serial/server/Server.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ServerBridge.java [moved from serial/server/ServerBridge.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ServerObject.java [moved from serial/server/ServerObject.java with 100% similarity]
src/be/nikiroo/utils/serial/server/ServerString.java [moved from serial/server/ServerString.java with 100% similarity]
src/be/nikiroo/utils/streams/Base64.java [moved from streams/Base64.java with 100% similarity]
src/be/nikiroo/utils/streams/Base64InputStream.java [moved from streams/Base64InputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/Base64OutputStream.java [moved from streams/Base64OutputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/BufferedInputStream.java [moved from streams/BufferedInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/BufferedOutputStream.java [moved from streams/BufferedOutputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/MarkableFileInputStream.java [moved from streams/MarkableFileInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/NextableInputStream.java [moved from streams/NextableInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/NextableInputStreamStep.java [moved from streams/NextableInputStreamStep.java with 100% similarity]
src/be/nikiroo/utils/streams/ReplaceInputStream.java [moved from streams/ReplaceInputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/ReplaceOutputStream.java [moved from streams/ReplaceOutputStream.java with 100% similarity]
src/be/nikiroo/utils/streams/StreamUtils.java [moved from streams/StreamUtils.java with 100% similarity]
src/be/nikiroo/utils/test/TestCase.java [moved from test/TestCase.java with 100% similarity]
src/be/nikiroo/utils/test/TestLauncher.java [moved from test/TestLauncher.java with 100% similarity]
src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java [moved from test_code/BufferedInputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java [moved from test_code/BufferedOutputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/BundleTest.java [moved from test_code/BundleTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/CryptUtilsTest.java [moved from test_code/CryptUtilsTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/IOUtilsTest.java [moved from test_code/IOUtilsTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/NextableInputStreamTest.java [moved from test_code/NextableInputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/ProgressTest.java [moved from test_code/ProgressTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java [moved from test_code/ReplaceInputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java [moved from test_code/ReplaceOutputStreamTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/SerialServerTest.java [moved from test_code/SerialServerTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/SerialTest.java [moved from test_code/SerialTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/StringUtilsTest.java [moved from test_code/StringUtilsTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/TempFilesTest.java [moved from test_code/TempFilesTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/Test.java [moved from test_code/Test.java with 100% similarity]
src/be/nikiroo/utils/test_code/VersionTest.java [moved from test_code/VersionTest.java with 100% similarity]
src/be/nikiroo/utils/test_code/bundle_test.properties [moved from test_code/bundle_test.properties with 100% similarity]
src/be/nikiroo/utils/ui/BreadCrumbsBar.java [moved from ui/BreadCrumbsBar.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigEditor.java [moved from ui/ConfigEditor.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItem.java [moved from ui/ConfigItem.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemBase.java [moved from ui/ConfigItemBase.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemBoolean.java [moved from ui/ConfigItemBoolean.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemBrowse.java [moved from ui/ConfigItemBrowse.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemColor.java [moved from ui/ConfigItemColor.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemCombobox.java [moved from ui/ConfigItemCombobox.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemInteger.java [moved from ui/ConfigItemInteger.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemLocale.java [moved from ui/ConfigItemLocale.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemPassword.java [moved from ui/ConfigItemPassword.java with 100% similarity]
src/be/nikiroo/utils/ui/ConfigItemString.java [moved from ui/ConfigItemString.java with 100% similarity]
src/be/nikiroo/utils/ui/DataNode.java [moved from ui/DataNode.java with 100% similarity]
src/be/nikiroo/utils/ui/DataTree.java [moved from ui/DataTree.java with 100% similarity]
src/be/nikiroo/utils/ui/DelayWorker.java [moved from ui/DelayWorker.java with 100% similarity]
src/be/nikiroo/utils/ui/ImageTextAwt.java [moved from ui/ImageTextAwt.java with 100% similarity]
src/be/nikiroo/utils/ui/ImageUtilsAwt.java [moved from ui/ImageUtilsAwt.java with 100% similarity]
src/be/nikiroo/utils/ui/ListModel.java [moved from ui/ListModel.java with 100% similarity]
src/be/nikiroo/utils/ui/ListSnapshot.java [moved from ui/ListSnapshot.java with 100% similarity]
src/be/nikiroo/utils/ui/ListenerItem.java [moved from ui/ListenerItem.java with 100% similarity]
src/be/nikiroo/utils/ui/ListenerPanel.java [moved from ui/ListenerPanel.java with 100% similarity]
src/be/nikiroo/utils/ui/NavBar.java [moved from ui/NavBar.java with 100% similarity]
src/be/nikiroo/utils/ui/ProgressBar.java [moved from ui/ProgressBar.java with 100% similarity]
src/be/nikiroo/utils/ui/TreeCellSpanner.java [moved from ui/TreeCellSpanner.java with 100% similarity]
src/be/nikiroo/utils/ui/TreeModelTransformer.java [moved from ui/TreeModelTransformer.java with 100% similarity]
src/be/nikiroo/utils/ui/TreeSnapshot.java [moved from ui/TreeSnapshot.java with 100% similarity]
src/be/nikiroo/utils/ui/UIUtils.java [moved from ui/UIUtils.java with 100% similarity]
src/be/nikiroo/utils/ui/WaitingDialog.java [moved from ui/WaitingDialog.java with 100% similarity]
src/be/nikiroo/utils/ui/WrapLayout.java [moved from ui/WrapLayout.java with 100% similarity]
src/be/nikiroo/utils/ui/ZoomBox.java [moved from ui/ZoomBox.java with 100% similarity]
src/be/nikiroo/utils/ui/compat/DefaultListModel6.java [moved from ui/compat/DefaultListModel6.java with 100% similarity]
src/be/nikiroo/utils/ui/compat/JList6.java [moved from ui/compat/JList6.java with 100% similarity]
src/be/nikiroo/utils/ui/compat/ListCellRenderer6.java [moved from ui/compat/ListCellRenderer6.java with 100% similarity]
src/be/nikiroo/utils/ui/compat/ListModel6.java [moved from ui/compat/ListModel6.java with 100% similarity]
src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java [moved from ui/test/ProgressBarManualTest.java with 100% similarity]
src/be/nikiroo/utils/ui/test/TestUI.java [moved from ui/test/TestUI.java with 100% similarity]
src/jexer/.classpath [new file with mode: 0644]
src/jexer/.gitignore [new file with mode: 0644]
src/jexer/.project [new file with mode: 0644]
src/jexer/EditMenuUser.java [new file with mode: 0644]
src/jexer/Scrollable.java [new file with mode: 0644]
src/jexer/TAction.java [new file with mode: 0644]
src/jexer/TApplication.java [new file with mode: 0644]
src/jexer/TApplication.properties [new file with mode: 0644]
src/jexer/TButton.java [new file with mode: 0644]
src/jexer/TCalendar.java [new file with mode: 0644]
src/jexer/TCheckBox.java [new file with mode: 0644]
src/jexer/TComboBox.java [new file with mode: 0644]
src/jexer/TCommand.java [new file with mode: 0644]
src/jexer/TDesktop.java [new file with mode: 0644]
src/jexer/TDirectoryList.java [new file with mode: 0644]
src/jexer/TEditColorThemeWindow.java [new file with mode: 0644]
src/jexer/TEditColorThemeWindow.properties [new file with mode: 0644]
src/jexer/TEditorWidget.java [new file with mode: 0644]
src/jexer/TEditorWindow.java [new file with mode: 0644]
src/jexer/TEditorWindow.properties [new file with mode: 0644]
src/jexer/TExceptionDialog.java [new file with mode: 0644]
src/jexer/TExceptionDialog.properties [new file with mode: 0644]
src/jexer/TField.java [new file with mode: 0644]
src/jexer/TFileOpenBox.java [new file with mode: 0644]
src/jexer/TFileOpenBox.properties [new file with mode: 0644]
src/jexer/TFontChooserWindow.java [new file with mode: 0644]
src/jexer/TFontChooserWindow.properties [new file with mode: 0644]
src/jexer/THScroller.java [new file with mode: 0644]
src/jexer/THelpWindow.java [new file with mode: 0644]
src/jexer/THelpWindow.properties [new file with mode: 0644]
src/jexer/TImage.java [new file with mode: 0644]
src/jexer/TImageWindow.java [new file with mode: 0644]
src/jexer/TImageWindow.properties [new file with mode: 0644]
src/jexer/TInputBox.java [new file with mode: 0644]
src/jexer/TKeypress.java [new file with mode: 0644]
src/jexer/TLabel.java [new file with mode: 0644]
src/jexer/TList.java [new file with mode: 0644]
src/jexer/TMessageBox.java [new file with mode: 0644]
src/jexer/TMessageBox.properties [new file with mode: 0644]
src/jexer/TPanel.java [new file with mode: 0644]
src/jexer/TPasswordField.java [new file with mode: 0644]
src/jexer/TProgressBar.java [new file with mode: 0644]
src/jexer/TRadioButton.java [new file with mode: 0644]
src/jexer/TRadioGroup.java [new file with mode: 0644]
src/jexer/TScrollableWidget.java [new file with mode: 0644]
src/jexer/TScrollableWindow.java [new file with mode: 0644]
src/jexer/TSpinner.java [new file with mode: 0644]
src/jexer/TSplitPane.java [new file with mode: 0644]
src/jexer/TStatusBar.java [new file with mode: 0644]
src/jexer/TTableWidget.java [new file with mode: 0644]
src/jexer/TTableWindow.java [new file with mode: 0644]
src/jexer/TTableWindow.properties [new file with mode: 0644]
src/jexer/TTerminalWidget.java [new file with mode: 0644]
src/jexer/TTerminalWidget.properties [new file with mode: 0644]
src/jexer/TTerminalWindow.java [new file with mode: 0644]
src/jexer/TTerminalWindow.properties [new file with mode: 0644]
src/jexer/TText.java [new file with mode: 0644]
src/jexer/TTimer.java [new file with mode: 0644]
src/jexer/TVScroller.java [new file with mode: 0644]
src/jexer/TWidget.java [new file with mode: 0644]
src/jexer/TWindow.java [new file with mode: 0644]
src/jexer/backend/Backend.java [new file with mode: 0644]
src/jexer/backend/ECMA48Backend.java [new file with mode: 0644]
src/jexer/backend/ECMA48Terminal.java [new file with mode: 0644]
src/jexer/backend/GenericBackend.java [new file with mode: 0644]
src/jexer/backend/GlyphMaker.java [new file with mode: 0644]
src/jexer/backend/HeadlessBackend.java [new file with mode: 0644]
src/jexer/backend/LogicalScreen.java [new file with mode: 0644]
src/jexer/backend/MultiBackend.java [new file with mode: 0644]
src/jexer/backend/MultiScreen.java [new file with mode: 0644]
src/jexer/backend/Screen.java [new file with mode: 0644]
src/jexer/backend/SessionInfo.java [new file with mode: 0644]
src/jexer/backend/SwingBackend.java [new file with mode: 0644]
src/jexer/backend/SwingComponent.java [new file with mode: 0644]
src/jexer/backend/SwingSessionInfo.java [new file with mode: 0644]
src/jexer/backend/SwingTerminal.java [new file with mode: 0644]
src/jexer/backend/TSessionInfo.java [new file with mode: 0644]
src/jexer/backend/TTYSessionInfo.java [new file with mode: 0644]
src/jexer/backend/TWindowBackend.java [new file with mode: 0644]
src/jexer/backend/TerminalReader.java [new file with mode: 0644]
src/jexer/backend/package-info.java [new file with mode: 0644]
src/jexer/bits/Cell.java [new file with mode: 0644]
src/jexer/bits/CellAttributes.java [new file with mode: 0644]
src/jexer/bits/Clipboard.java [new file with mode: 0644]
src/jexer/bits/Color.java [new file with mode: 0644]
src/jexer/bits/ColorTheme.java [new file with mode: 0644]
src/jexer/bits/GraphicsChars.java [new file with mode: 0644]
src/jexer/bits/MnemonicString.java [new file with mode: 0644]
src/jexer/bits/StringUtils.java [new file with mode: 0644]
src/jexer/bits/package-info.java [new file with mode: 0644]
src/jexer/demos/Demo1.java [new file with mode: 0644]
src/jexer/demos/Demo2.java [new file with mode: 0644]
src/jexer/demos/Demo2.properties [new file with mode: 0644]
src/jexer/demos/Demo3.java [new file with mode: 0644]
src/jexer/demos/Demo4.java [new file with mode: 0644]
src/jexer/demos/Demo5.java [new file with mode: 0644]
src/jexer/demos/Demo5.properties [new file with mode: 0644]
src/jexer/demos/Demo6.java [new file with mode: 0644]
src/jexer/demos/Demo6.properties [new file with mode: 0644]
src/jexer/demos/Demo7.java [new file with mode: 0644]
src/jexer/demos/Demo7.properties [new file with mode: 0644]
src/jexer/demos/Demo8.java [new file with mode: 0644]
src/jexer/demos/Demo8.properties [new file with mode: 0644]
src/jexer/demos/DemoApplication.java [new file with mode: 0644]
src/jexer/demos/DemoApplication.properties [new file with mode: 0644]
src/jexer/demos/DemoCheckBoxWindow.java [new file with mode: 0644]
src/jexer/demos/DemoCheckBoxWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoEditorWindow.java [new file with mode: 0644]
src/jexer/demos/DemoEditorWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoMainWindow.java [new file with mode: 0644]
src/jexer/demos/DemoMainWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoMsgBoxWindow.java [new file with mode: 0644]
src/jexer/demos/DemoMsgBoxWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoTableWindow.java [new file with mode: 0644]
src/jexer/demos/DemoTableWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoTextFieldWindow.java [new file with mode: 0644]
src/jexer/demos/DemoTextFieldWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoTextWindow.java [new file with mode: 0644]
src/jexer/demos/DemoTextWindow.properties [new file with mode: 0644]
src/jexer/demos/DemoTreeViewWindow.java [new file with mode: 0644]
src/jexer/demos/DemoTreeViewWindow.properties [new file with mode: 0644]
src/jexer/demos/DesktopDemo.java [new file with mode: 0644]
src/jexer/demos/DesktopDemoApplication.java [new file with mode: 0644]
src/jexer/demos/DesktopDemoApplication.properties [new file with mode: 0644]
src/jexer/demos/package-info.java [new file with mode: 0644]
src/jexer/event/TCommandEvent.java [new file with mode: 0644]
src/jexer/event/TInputEvent.java [new file with mode: 0644]
src/jexer/event/TKeypressEvent.java [new file with mode: 0644]
src/jexer/event/TMenuEvent.java [new file with mode: 0644]
src/jexer/event/TMouseEvent.java [new file with mode: 0644]
src/jexer/event/TResizeEvent.java [new file with mode: 0644]
src/jexer/event/package-info.java [new file with mode: 0644]
src/jexer/examples/imgls [new file with mode: 0755]
src/jexer/help/HelpFile.java [new file with mode: 0644]
src/jexer/help/HelpFile.properties [new file with mode: 0644]
src/jexer/help/Link.java [new file with mode: 0644]
src/jexer/help/THelpText.java [new file with mode: 0644]
src/jexer/help/TParagraph.java [new file with mode: 0644]
src/jexer/help/TWord.java [new file with mode: 0644]
src/jexer/help/Topic.java [new file with mode: 0644]
src/jexer/help/Topic.properties [new file with mode: 0644]
src/jexer/help/package-info.java [new file with mode: 0644]
src/jexer/io/ReadTimeoutException.java [new file with mode: 0644]
src/jexer/io/TimeoutInputStream.java [new file with mode: 0644]
src/jexer/io/package-info.java [new file with mode: 0644]
src/jexer/layout/BoxLayoutManager.java [new file with mode: 0644]
src/jexer/layout/LayoutManager.java [new file with mode: 0644]
src/jexer/layout/StretchLayoutManager.java [new file with mode: 0644]
src/jexer/layout/package-info.java [new file with mode: 0644]
src/jexer/menu/TMenu.java [new file with mode: 0644]
src/jexer/menu/TMenu.properties [new file with mode: 0644]
src/jexer/menu/TMenuItem.java [new file with mode: 0644]
src/jexer/menu/TMenuSeparator.java [new file with mode: 0644]
src/jexer/menu/TSubMenu.java [new file with mode: 0644]
src/jexer/menu/package-info.java [new file with mode: 0644]
src/jexer/net/TelnetInputStream.java [new file with mode: 0644]
src/jexer/net/TelnetOutputStream.java [new file with mode: 0644]
src/jexer/net/TelnetServerSocket.java [new file with mode: 0644]
src/jexer/net/TelnetSocket.java [new file with mode: 0644]
src/jexer/net/package-info.java [new file with mode: 0644]
src/jexer/package-info.java [new file with mode: 0644]
src/jexer/resources/help.xml [new file with mode: 0644]
src/jexer/resources/jexer_logo_128.png [new file with mode: 0644]
src/jexer/resources/terminus-ttf-4.39/COPYING [new file with mode: 0644]
src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf [new file with mode: 0644]
src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf [new file with mode: 0644]
src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf [new file with mode: 0644]
src/jexer/screenshots/screenshot1_old.png [new file with mode: 0644]
src/jexer/teditor/Document.java [new file with mode: 0644]
src/jexer/teditor/Highlighter.java [new file with mode: 0644]
src/jexer/teditor/Line.java [new file with mode: 0644]
src/jexer/teditor/Word.java [new file with mode: 0644]
src/jexer/teditor/package-info.java [new file with mode: 0644]
src/jexer/tterminal/DECCharacterSets.java [new file with mode: 0644]
src/jexer/tterminal/DisplayLine.java [new file with mode: 0644]
src/jexer/tterminal/DisplayListener.java [new file with mode: 0644]
src/jexer/tterminal/ECMA48.java [new file with mode: 0644]
src/jexer/tterminal/Sixel.java [new file with mode: 0644]
src/jexer/tterminal/package-info.java [new file with mode: 0644]
src/jexer/ttree/TDirectoryTreeItem.java [new file with mode: 0644]
src/jexer/ttree/TTreeItem.java [new file with mode: 0644]
src/jexer/ttree/TTreeView.java [new file with mode: 0644]
src/jexer/ttree/TTreeViewWidget.java [new file with mode: 0644]
src/jexer/ttree/TTreeViewWindow.java [new file with mode: 0644]
src/jexer/ttree/package-info.java [new file with mode: 0644]
test/expected_test.story/cbz.cbz [new file with mode: 0644]
test/expected_test.story/epub.epub [new file with mode: 0644]
test/expected_test.story/html/html.info [new file with mode: 0644]
test/expected_test.story/html/html.txt [new file with mode: 0644]
test/expected_test.story/html/index.html [new file with mode: 0644]
test/expected_test.story/html/style.css [new file with mode: 0644]
test/expected_test.story/info_text [new file with mode: 0644]
test/expected_test.story/info_text.info [new file with mode: 0644]
test/expected_test.story/latex.tex [new file with mode: 0644]
test/expected_test.story/text.txt [new file with mode: 0644]
test/test.story [new file with mode: 0644]

diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..9cecc1d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    {one line to give the program's name and a brief idea of what it does.}
+    Copyright (C) {year}  {name of author}
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    {project}  Copyright (C) {year}  {fullname}
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/Makefile.base b/Makefile.base
new file mode 100644 (file)
index 0000000..0d365b8
--- /dev/null
@@ -0,0 +1,243 @@
+# Makefile base template
+# 
+# Version:
+# - 1.0.0: add a version comment
+# - 1.1.0: add 'help', 'sjar'
+# - 1.2.0: add 'apk'
+# - 1.2.1: improve 'apk' and add 'android'
+# - 1.3.0: add 'man' for man(ual) pages
+# - 1.4.0: remove android stuff (not working anyway)
+# - 1.5.0: include sources and readme/changelog in jar
+# - 1.5.1: include binaries from libs/bin/ into the jar
+
+# Required parameters (the commented out ones are supposed to be per project):
+
+#MAIN = path to main java source to compile
+#MORE = path to supplementary needed resources not linked from MAIN
+#NAME = name of project (used for jar output file)
+#PREFIX = usually /usr/local (where to install the program)
+#TEST = path to main test source to compile
+#JAR_FLAGS += a list of things to pack, each usually prefixed with "-C bin/"
+#SJAR_FLAGS += a list of things to pack, each usually prefixed with "-C src/",
+#              for *-sources.jar files
+#TEST_PARAMS = any parameter to pass to the test runnable when "test-run"
+
+JAVAC = javac
+JAVAC_FLAGS += -encoding UTF-8 -d ./bin/ -cp ./src/
+JAVA = java
+JAVA_FLAGS += -cp ./bin/
+JAR = jar
+RJAR = java
+RJAR_FLAGS += -jar
+
+all: build jar man
+
+help:
+       @echo "Usual options:"
+       @echo "=============="
+       @echo " make            : to build the jar file and man pages IF possible"
+       @echo " make help       : to get this help screen"
+       @echo " make libs       : to update the libraries into src/"
+       @echo " make build      : to update the binaries (not the jar)"
+       @echo " make test       : to update the test binaries"
+       @echo " make build jar  : to update the binaries and jar file"
+       @echo " make sjar       : to create the sources jar file"
+       @echo " make clean      : to clean the directory of intermediate files"
+       @echo " make mrpropre   : to clean the directory of all outputs"
+       @echo " make run        : to run the program from the binaries"
+       @echo " make run-test   : to run the test program from the binaries"
+       @echo " make jrun       : to run the program from the jar file"
+       @echo " make install    : to install the application into $$PREFIX"
+       @echo " make ifman      : to make the manual pages (if pandoc is found)"
+       @echo " make man        : to make the manual pages (requires pandoc)"
+
+.PHONY: all clean mrproper mrpropre build run jrun jar sjar resources test-resources install libs ifman man love
+
+bin:
+       @mkdir -p bin
+
+jar: $(NAME).jar
+
+sjar: $(NAME)-sources.jar
+
+build: resources
+       @echo Compiling program...
+       @echo " src/$(MAIN)"
+       @$(JAVAC) $(JAVAC_FLAGS) "src/$(MAIN).java"
+       @[ "$(MORE)" = "" ] || for sup in $(MORE); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+test: test-resources
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Compiling test program...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       @[ "$(TEST)"  = "" ] || for sup in $(TEST); do \
+               echo "  src/$$sup" ;\
+               $(JAVAC) $(JAVAC_FLAGS) "src/$$sup.java" ; \
+       done
+
+clean:
+       rm -rf bin/
+       @echo Removing sources taken from libs...
+       @for lib in libs/*-sources.jar libs/*-sources.patch.jar; do \
+               if [ "$$lib" != 'libs/*-sources.jar' -a "$$lib" != 'libs/*-sources.patch.jar' ]; then \
+                       basename "$$lib"; \
+                       jar tf "$$lib" | while read -r ln; do \
+                               [ -f "src/$$ln" ] && rm "src/$$ln"; \
+                       done; \
+                       jar tf "$$lib" | tac | while read -r ln; do \
+                               [ -d "src/$$ln" ] && rmdir "src/$$ln" 2>/dev/null || true; \
+                       done; \
+               fi \
+       done
+
+mrproper: mrpropre
+
+mrpropre: clean
+       rm -f $(NAME).jar
+       rm -f $(NAME)-sources.jar
+       rm -f $(NAME).apk
+       rm -f $(NAME)-debug.apk
+       [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`.jar"
+       [ ! -e VERSION ] || rm -f "$(NAME)-`cat VERSION`-sources.jar"
+
+love:
+       @echo " ...not war."
+
+resources: libs
+       @echo Copying resources into bin/...
+       @cd src && find . | grep -v '\.java$$' | grep -v '/test/' | while read -r ln; do \
+               if [ -f "$$ln" ]; then \
+                       dir="`dirname "$$ln"`"; \
+                       mkdir -p "../bin/$$dir" ; \
+                       cp "$$ln" "../bin/$$ln" ; \
+               fi ; \
+       done
+       @cp VERSION bin/
+
+test-resources: resources
+       @echo Copying test resources into bin/...
+       @cd src && find . | grep -v '\.java$$' | grep '/test/' | while read -r ln; do \
+               if [ -f "$$ln" ]; then \
+                       dir="`dirname "$$ln"`"; \
+                       mkdir -p "../bin/$$dir" ; \
+                       cp "$$ln" "../bin/$$ln" ; \
+               fi ; \
+       done
+
+libs: bin
+       @[ -e bin/libs -o ! -d libs ] || echo Extracting sources from libs...
+       @[ -e bin/libs -o ! -d libs ] || (cd src && for lib in ../libs/*-sources.jar ../libs/*-sources.patch.jar; do \
+               if [ "$$lib" != '../libs/*-sources.jar' -a "$$lib" != '../libs/*-sources.patch.jar' ]; then \
+                       basename "$$lib"; \
+                       jar xf "$$lib"; \
+               fi \
+       done )
+       @[ ! -d libs ] || touch bin/libs
+
+$(NAME)-sources.jar: libs
+       @ls *.md >/dev/null || cp VERSION README.md
+       @echo Making sources JAR file...
+       @echo > bin/manifest
+       @[ "$(SJAR_FLAGS)" != "" ] || echo No sources JAR file defined, skipping
+       @[ "$(SJAR_FLAGS)"  = "" ] || echo Creating $(NAME)-sources.jar...
+       @[ "$(SJAR_FLAGS)"  = "" ] || $(JAR) cfm $(NAME)-sources.jar bin/manifest -C ./ *.md $(SJAR_FLAGS)
+       @[ "$(SJAR_FLAGS)"  = "" ] || [ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`-sources.jar"...
+       @[ "$(SJAR_FLAGS)"  = "" ] || [ ! -e VERSION ] || cp $(NAME)-sources.jar "$(NAME)-`cat VERSION`-sources.jar"
+
+$(NAME).jar: resources
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @ls *.md >/dev/null || cp VERSION README.md
+       @echo "Copying documentation into bin/..."
+       @cp -r *.md bin/ || cp VERSION bin/no-documentation.md
+       @[ ! -d libs/bin/ ] || echo "Copying additional binaries from libs/bin/ into bin/..."
+       @[ ! -d libs/bin/ ] || cp -r libs/bin/* bin/
+       @echo "Copying sources into bin/..."
+       @cp -r src/* bin/
+       @echo "Making jar..."
+       @echo "Main-Class: `echo "$(MAIN)" | sed 's:/:.:g'`" > bin/manifest
+       @echo >> bin/manifest
+       $(JAR) cfm $(NAME).jar bin/manifest -C ./ *.md $(JAR_FLAGS)
+       @[ ! -e VERSION ] || echo Copying to "$(NAME)-`cat VERSION`.jar"...
+       @[ ! -e VERSION ] || cp $(NAME).jar "$(NAME)-`cat VERSION`.jar"
+
+run: 
+       @[ -e bin/$(MAIN).class ] || echo You need to build the sources
+       @[ -e bin/$(MAIN).class ]
+       @echo Running "$(NAME)"...
+       $(JAVA) $(JAVA_FLAGS) $(MAIN)
+
+jrun:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       @echo Running "$(NAME).jar"...
+       $(RJAR) $(RJAR_FLAGS) $(NAME).jar
+
+run-test: 
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ] || echo You need to build the test sources
+       @[ "$(TEST)" = "" -o -e "bin/$(TEST).class" ]
+       @echo Running tests for "$(NAME)"...
+       @[ "$(TEST)" != "" ] || echo No test sources defined.
+       [ "$(TEST)"  = "" ] || ( clear ; $(JAVA) $(JAVA_FLAGS) $(TEST) $(TEST_PARAMS) )
+
+install:
+       @[ -e $(NAME).jar ] || echo You need to build the jar
+       @[ -e $(NAME).jar ]
+       mkdir -p "$(PREFIX)/lib" "$(PREFIX)/bin"
+       cp $(NAME).jar "$(PREFIX)/lib/"
+       echo "#!/bin/sh" > "$(PREFIX)/bin/$(NAME)"
+       echo "$(RJAR) $(RJAR_FLAGS) \"$(PREFIX)/lib/$(NAME).jar\" \"\$$@\"" >> "$(PREFIX)/bin/$(NAME)"
+       chmod a+rx "$(PREFIX)/bin/$(NAME)"
+       if [ -e "man/man1/$(NAME).1" ]; then \
+               cp -r man/ "$(PREFIX)"/share/; \
+       fi
+
+ifman:
+       @if pandoc -v >/dev/null 2>&1; then \
+               make man; \
+       else \
+               echo "man pages not generated: "'`'"pandoc' required"; \
+       fi
+
+man: 
+       @echo Checking for possible manual pages...
+       @if [ -e README.md ]; then \
+               echo Sources found for man pages; \
+               if pandoc -v >/dev/null 2>&1; then \
+                       ls README*.md 2>/dev/null \
+                                       | grep 'README\(-..\|\)\.md' \
+                                       | while read man; do \
+                               echo "  Processing page $$lang..."; \
+                               lang="`echo "$$man" \
+                                       | sed 's:README\.md:en:' \
+                                       | sed 's:README-\(.*\)\.md:\1:'`"; \
+                               mkdir -p man/"$$lang"/man1; \
+                               ( \
+                                       echo ".TH \"${NAME}\" 1 `\
+                                               date +%Y-%m-%d\
+                                               ` \"version `cat VERSION`\""; \
+                                       echo; \
+                                       UNAME="`echo "${NAME}" \
+                                               | sed 's:\(.*\):\U\1:g'`"; \
+                                       ( \
+                                               cat "$$man" | head -n1 \
+       | sed 's:.*(README\(-fr\|\)\.md).*::g'; \
+                                               cat "$$man" | tail -n+2; \
+                                       ) | sed 's:^#\(#.*\):\1:g' \
+       | sed 's:^\(#.*\):\U\1:g;s:# *'"$$UNAME"':# NAME\n'"${NAME}"' \\- :g' \
+       | sed 's:--:——:g' \
+       | pandoc -f markdown -t man | sed 's:——:--:g' ; \
+                               ) > man/"$$lang"/man1/"${NAME}.1"; \
+                       done; \
+                       mkdir -p "man/man1"; \
+                       cp man/en/man1/"${NAME}".1 man/man1/; \
+               else \
+                       echo "man pages generation: pandoc required" >&2; \
+                       false; \
+               fi; \
+       fi;
+
diff --git a/README-fr.md b/README-fr.md
new file mode 100644 (file)
index 0000000..d6dbd8e
--- /dev/null
@@ -0,0 +1,157 @@
+[English](README.md) Français
+
+# Fanfix
+
+Fanfix est un petit programme Java qui peut télécharger des histoires sur internet et les afficher hors ligne.
+
+## 🔴 Ceci est le programme serveur et command-line
+
+Vous pouvez aussi utiliser :
+- le client graphique [Fanfix-swing](https://github.com/nikiroo/fanfix-swing/)
+- le client en mode TUI [Fanfix-jexer](https://github.com/nikiroo/fanfix-jexer/)
+
+## Synopsis
+
+- ```fanfix``` --import [*URL*]
+- ```fanfix``` --export [*id*] [*output_type*] [*target*]
+- ```fanfix``` --convert [*URL*] [*output_type*] [*target*] (+info)
+- ```fanfix``` --read [*id*] ([*chapter number*])
+- ```fanfix``` --read-url [*URL*] ([*chapter number*])
+- ```fanfix``` --search
+- ```fanfix``` --search [*where*] [*keywords*] (page [*page*]) (item [*item*])
+- ```fanfix``` --search-tag
+- ```fanfix``` --search-tag [*index 1*]... (page [*page*]) (item [*item*])
+- ```fanfix``` --list
+- ```fanfix``` --server [*key*] [*port*]
+- ```fanfix``` --stop-server [*key*] [*port*]
+- ```fanfix``` --remote [*key*] [*host*] [*port*]
+- ```fanfix``` --help
+
+## Description
+
+(Si vous voulez juste voir les derniers changements, vous pouvez regarder le [Changelog](changelog-fr.md) -- remarquez que le programme affiche le changelog si une version plus récente est détectée depuis la version 1.4.0.)
+
+Le fonctionnement du programme est assez simple : il converti une URL venant d'un site supporté en un fichier .epub pour les histoires ou .cbz pour les comics (d'autres options d'enregistrement sont disponibles, comme du texte simple, du HTML...)
+
+Pour vous aider à organiser vos histoires, il peut aussi servir de bibliothèque locale vous permettant :
+
+- d'importer une histoire depuis son URL (ou depuis un fichier)
+- d'exporter une histoire dans un des formats supportés vers un fichier
+- d'afficher une histoire en mode texte
+- via [fanfix-swing](https://github.com/nikiroo/fanfix-swing/): d'afficher une histoire en mode GUI **lui-même** ([fanfix-swing](https://github.com/nikiroo/fanfix-swing/)) ou **en appelant un programme natif pour lire le fichier** (potentiellement converti en HTML avant, pour que n'importe quel navigateur web puisse l'afficher)
+
+### Sites supportés
+
+Pour le moment, les sites suivants sont supportés :
+
+- http://FimFiction.net/ : fanfictions dévouées à la série My Little Pony
+- http://Fanfiction.net/ : fanfictions venant d'une multitude d'univers différents, depuis les shows télévisés aux livres en passant par les jeux-vidéos
+- http://mangahub.io/ : un site répertoriant une quantité non négligeable de mangas (English)
+- https://e621.net/ : un site Furry proposant des comics, y compris de MLP
+- https://sofurry.com/ : même chose, mais orienté sur les histoires plutôt que les images
+- https://e-hentai.org/ : support ajouté sur demande : n'hésitez pas à demander un site !
+- http://mangas-lecture-en-ligne.fr/ : un site proposant beaucoup de mangas, en français
+
+### Types de fichiers supportés
+
+Nous supportons les types de fichiers suivants (aussi bien en entrée qu'en sortie) :
+
+- epub : les fichiers .epub créés avec Fanfix (nous ne supportons pas les autres fichiers .epub, du moins pour le moment)
+- text : les histoires enregistrées en texte (.txt), avec quelques règles spécifiques :
+       - le titre doit être sur la première ligne
+       - l'auteur (précédé de rien, ```Par ```, ```De ``` ou ```©```) doit être sur la deuxième ligne, optionnellement suivi de la date de publication entre parenthèses (i.e., ```Par Quelqu'un (3 octobre 1998)```)
+       - les chapitres doivent être déclarés avec ```Chapitre x``` ou ```Chapitre x: NOM DU CHAPTITRE```, où ```x``` est le numéro du chapitre
+       - une description de l'histoire doit être donnée en tant que chaptire 0
+       - une image de couverture peut être présente avec le même nom de fichier que l'histoire, mais une extension .png, .jpeg ou .jpg
+- info_text : fort proche du format texte, mais avec un fichier .info accompagnant l'histoire pour y enregistrer quelques metadata (le fichier de metadata est supposé être créé par Fanfix, ou être compatible avec)
+- cbz : les fichiers .cbz (une collection d'images zipées), de préférence créés avec Fanfix (même si les autres .cbz sont aussi supportés, mais sans la majorité des metadata de Fanfix dans ce cas)
+- html : les fichiers HTML que vous pouvez ouvrir avec n'importe quel navigateur ; remarquez que Fanfix créera un répertoire pour y mettre les fichiers nécessaires, dont un fichier ```index.html``` pour afficher le tout -- nous ne supportons en entrée que les fichiers HTML créés par Fanfix
+
+### Plateformes supportées
+
+Toute plateforme supportant Java 1.6 devrait suffire.
+
+Le programme a été testé sur Linux (Debian, Slackware et Ubuntu), MacOS X et Windows pour le moment, mais n'hésitez pas à nous informer si vous l'essayez sur un autre système.
+
+Si vous avez des difficultés pour le compiler avec une version supportée de Java (1.6+), contactez-nous.
+
+## Options
+
+Vous pouvez démarrer le programme de deux façons :
+
+- ```java -jar fanfix.jar```
+- ```fanfix``` (si vous avez utilisé *make install*)
+
+Les arguments suivants sont supportés :
+
+- ```--import [URL]```: importer une histoire dans la librairie
+- ```--export [id] [output_type] [target]```: exporter l'histoire "id" vers le fichier donné
+- ```--convert [URL] [output_type] [target] (+info)```: convertir l'histoire vers le fichier donné, et forcer l'ajout d'un fichier .info si +info est utilisé
+- ```--read [id] ([chapter number])```: afficher l'histoire "id"
+- ```--read-url [URL] ([chapter number])```: convertir l'histoire et la lire à la volée, sans la sauver
+- ```--search```: liste les sites supportés (```where```)
+- ```--search [where] [keywords] (page [page]) (item [item])```: lance une recherche et affiche les résultats de la page ```page``` (page 1 par défaut), et de l'item ```item``` spécifique si demandé
+- ```--tag [where]```: liste tous les tags supportés par ce site web
+- ```--tag [index 1]... (page [page]) (item [item])```: affine la recherche, tag par tag, et affiche si besoin les sous-tags, les histoires ou les infos précises de l'histoire demandée
+- ```--list```: lister les histoires presentes dans la librairie et leurs IDs
+- ```--server [key] [port]```: démarrer un serveur d'histoires sur ce port
+- ```--stop-server [key] [port]```: arrêter le serveur distant sur ce port (key doit avoir la même valeur)
+- ```--remote [key] [host] [port]```: contacter ce server au lieu de la librairie habituelle (key doit avoir la même valeur)
+- ```--help```: afficher la liste des options disponibles
+- ```--version```: retourne la version du programme
+
+### Environnement
+
+Certaines variables d'environnement sont reconnues par le programme :
+
+- ```LANG=en```: forcer la langue du programme en anglais
+- ```CONFIG_DIR=$HOME/.fanfix```: utilise ce répertoire pour les fichiers de configuration du programme (et copie les fichiers de configuration par défaut si besoin)
+- ```NOUTF=1```: essaye d'utiliser des caractères non-unicode quand possible (cela peut avoir un impact sur les fichiers générés, pas uniquement sur les messages à l'utilisateur)
+- ```DEBUG=1```: force l'option ```DEBUG=true``` du fichier de configuration (pour afficher plus d'information en cas d'erreur)
+
+## Compilation
+
+```./configure.sh && make```
+
+Vous pouvez aussi importer les sources java dans, par exemple, [Eclipse](https://eclipse.org/), et faire un JAR exécutable depuis celui-ci.
+
+Quelques tests unitaires sont disponibles :
+
+```./configure.sh && make build test run-test```
+
+Si vous faites tourner les tests unitaires, sachez que certains fichiers flags peuvent les impacter:
+
+- ```test/VERBOSE```      : active le mode verbeux pour les erreurs
+- ```test/OFFLINE```      : ne permet pas au programme de télécharger des données
+- ```test/URLS```         : permet au programme de tester des URLs
+- ```test/FORCE_REFRESH```: force le nettoyage du cache
+
+Notez que le répertoire ```test/CACHE``` peut rester en place; il contient tous les fichiers téléchargés au moins une fois depuis le réseau par les tests unitaires (si vous autorisez les tests d'URLs, lancez les tests au moins une fois pour peupler le CACHE, puis activez le mode OFFLINE, ça marchera toujours).
+
+Les fichiers de test seront:
+
+- ```test/*.url```  : des URLs à télécharger en fichier texte (le contenu du fichier est l'URL)
+- ```test/*.story```: des histoires en mode texte (le contenu du fichier est l'histoire)
+
+### Librairies dépendantes (incluses)
+
+Nécessaires :
+
+- ```libs/nikiroo-utils-sources.jar```: quelques utilitaires partagés
+- [```libs/unbescape-sources.jar```](https://github.com/unbescape/unbescape): une librairie sympathique pour convertir du texte depuis/vers beaucoup de formats ; utilisée ici pour la partie HTML
+- [```libs/jsoup-sources.jar```](https://jsoup.org/): une libraririe pour parser du HTML
+- [```libs/JSON-java-20190722-sources.jar```](https://github.com/stleary/JSON-java): une libraririe pour parser du JSON
+
+Optionnelles :
+
+- [```libs/jexer-sources.jar```](https://github.com/klamonte/jexer): une petite librairie qui offre des widgets en mode TUI
+- [```pandoc```](http://pandoc.org/): pour générer les man pages depuis les fichiers README
+
+Rien d'autre, si ce n'est Java 1.6+.
+
+À noter : ```make libs``` exporte ces librairies dans le répertoire src/.
+
+## Auteur
+
+Fanfix a été écrit par Niki Roo <niki@nikiroo.be>
+
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..1ad3339
--- /dev/null
+++ b/README.md
@@ -0,0 +1,158 @@
+English [Français](README-fr.md)
+
+# Fanfix
+
+Fanfix is a small Java program that can download stories from some supported websites and render them offline.
+
+## 🔴 This is the command line and server program
+
+You can also use:
+- the graphical client [Fanfix-swing](https://github.com/nikiroo/fanfix-swing/)
+- the TUI client [Fanfix-jexer](https://github.com/nikiroo/fanfix-jexer/)
+
+## Synopsis
+
+- ```fanfix``` --import [*URL*]
+- ```fanfix``` --export [*id*] [*output_type*] [*target*]
+- ```fanfix``` --convert [*URL*] [*output_type*] [*target*] (+info)
+- ```fanfix``` --read [*id*] ([*chapter number*])
+- ```fanfix``` --read-url [*URL*] ([*chapter number*])
+- ```fanfix``` --search
+- ```fanfix``` --search [*where*] [*keywords*] (page [*page*]) (item [*item*])
+- ```fanfix``` --search-tag
+- ```fanfix``` --search-tag [*index 1*]... (page [*page*]) (item [*item*])
+- ```fanfix``` --list
+- ```fanfix``` --server [*key*] [*port*]
+- ```fanfix``` --stop-server [*key*] [*port*]
+- ```fanfix``` --remote [*key*] [*host*] [*port*]
+- ```fanfix``` --help
+
+## Description
+
+(If you are interested in the recent changes, please check the [Changelog](changelog.md) -- note that starting from version 1.4.0, the changelog is checked at startup.)
+
+This program will convert from a (supported) URL to an .epub file for stories or a .cbz file for comics (a few other output types are also available, like Plain Text, LaTeX, HTML...).
+
+To help organize your stories, it can also work as a local library so you can:
+
+- Import a story from its URL (or just from a file)
+- Export a story to a file (in any of the supported output types)
+- Display a story from the local library in text format in the console
+- Via [fanfix-swing](https://github.com/nikiroo/fanfix-swing/): Display a story from the local library graphically **by itself** ([fanfix-swing](https://github.com/nikiroo/fanfix-swing/)) or **by calling a native program to handle it** (potentially converted into HTML before hand, so any browser can open it)
+
+### Supported websites
+
+Currently, the following websites are supported:
+
+- http://FimFiction.net/: fan fictions devoted to the My Little Pony show
+- http://Fanfiction.net/: fan fictions of many, many different universes, from TV shows to novels to games
+- http://mangahub.io/: a well filled repository of mangas (English)
+- https://e621.net/: a Furry website supporting comics, including MLP
+- https://sofurry.com/: same thing, but story-oriented
+- https://e-hentai.org/: done upon request (so, feel free to ask for more websites!)
+- http://mangas-lecture-en-ligne.fr/: a website offering a lot of mangas (in French)
+
+### Support file types
+
+We support a few file types for local story conversion (both as input and as output):
+
+- epub: .epub files created by this program (we do not support "all" .epub files, at least for now)
+- text: local stories encoded in plain text format, with a few specific rules:
+       - the title must be on the first line
+       - the author (preceded by nothing, ```by ``` or ```©```) must be on the second line, possibly with the publication date in parenthesis (i.e., ```By Unknown (3rd October 1998)```)
+       - chapters must be declared with ```Chapter x``` or ```Chapter x: NAME OF THE CHAPTER```, where ```x``` is the chapter number
+       - a description of the story must be given as chapter number 0
+       - a cover image may be present with the same filename as the story, but a .png, .jpeg or .jpg extension
+- info_text: contains the same information as the text format, but with a companion .info file to store some metadata (the .info file is supposed to be created by Fanfix or compatible with it)
+- cbz: .cbz (collection of images) files, preferably created with Fanfix (but any .cbz file is supported, though without most of Fanfix metadata, obviously)
+- html: HTML files that you can open with any browser; note that it will create a directory structure with ```index.html``` as the main file -- we only support importing HTML files created by Fanfix
+
+### Supported platforms
+
+Any platform with at lest Java 1.6 on it should be ok.
+
+It has been tested on Linux (Debian, Slackware, Ubuntu), MacOS X and Windows for now, but feel free to inform us if you try it on another system.
+
+If you have any problems to compile it with a supported Java version (1.6+), please contact us.
+
+## Options
+
+You can start the program in two ways:
+
+- ```java -jar fanfix.jar```
+- ```fanfix``` (if you used *make install*)
+
+The following arguments are allowed:
+
+- ```--import [URL]```: import the story at URL into the local library
+- ```--export [id] [output_type] [target]```: export the story denoted by ID to the target file
+- ```--convert [URL] [output_type] [target] (+info)```: convert the story at URL into target, and force-add the .info and cover if +info is passed
+- ```--read [id] ([chapter number])```: read the given story denoted by ID from the library
+- ```--read-url [URL] ([chapter number])```: convert on the fly and read the story at URL, without saving it
+- ```--search```: list the supported websites (```where```)
+- ```--search [where] [keywords] (page [page]) (item [item])```: search on the supported website and display the given results page of stories it found, or the story details if asked
+- ```--tag [where]```: list all the tags supported by this website
+- ```--tag [index 1]... (page [page]) (item [item])```: search for the given stories or subtags, tag by tag, and display information about a specific page of results or about a specific item if requested
+- ```--list```: list the stories present in the library and their associated IDs
+- ```--server [key] [port]```: start a story server on this port
+- ```--stop-server [key] [port]```: stop the remote server running on this port (key must be set to the same value)
+- ```--remote [key] [host] [port]```: contact this server instead of the usual library (key must be set to the same value)
+- ```--help```: display the available options
+- ```--version```: return the version of the program
+
+### Environment
+
+Some environment variables are recognized by the program:
+
+- ```LANG=en```: force the language to English
+- ```CONFIG_DIR=$HOME/.fanfix```: use the given directory as a config directory (and copy the default configuration if needed)
+- ```NOUTF=1```: try to fallback to non-unicode values when possible (can have an impact on the resulting files, not only on user messages)
+- ```DEBUG=1```: force the ```DEBUG=true``` option of the configuration file (to show more information on errors)
+
+## Compilation
+
+```./configure.sh && make```
+
+You can also import the java sources into, say, [Eclipse](https://eclipse.org/), and create a runnable JAR file from there.
+
+There are some unit tests you can run, too:
+
+```./configure.sh && make build test run-test```
+
+If you run the unit tests, note that some flag files can impact them:
+
+- ```test/VERBOSE```      : enable verbose mode
+- ```test/OFFLINE```      : to forbid any downloading
+- ```test/URLS```         : to allow testing URLs
+- ```test/FORCE_REFRESH```: to force a clear of the cache
+
+Note that ```test/CACHE``` can be kept, as it will contain all internet related files you need (if you allow URLs, run the test once which will populate the CACHE then go OFFLINE, it will still work).
+
+The test files will be:
+
+- ```test/*.url```  : URL to download in text format, content = URL
+- ```test/*.story```: text mode story, content = story
+
+
+### Dependant libraries (included)
+
+Required:
+
+- ```libs/nikiroo-utils-sources.jar```: some shared utility functions
+- [```libs/unbescape-sources.jar```](https://github.com/unbescape/unbescape): a nice library to escape/unescape a lot of text formats; used here for HTML
+- [```libs/jsoup-sources.jar```](https://jsoup.org/): a library to parse HTML
+- [```libs/JSON-java-20190722-sources.jar```](https://github.com/stleary/JSON-java): a library to parse JSON
+
+Optional:
+
+- [```libs/jexer-sources.jar```](https://github.com/klamonte/jexer): a small library that offers TUI widgets
+- [```pandoc```](http://pandoc.org/): to generate the man pages from the README files
+
+Nothing else but Java 1.6+.
+
+Note that calling ```make libs``` will export the libraries into the src/ directory.
+
+## Author
+
+Fanfix was written by Niki Roo <niki@nikiroo.be>
+
diff --git a/TODO.md b/TODO.md
new file mode 100644 (file)
index 0000000..85f2deb
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,106 @@
+My current planning for Fanfix (but not everything appears on this list):
+
+- [ ] Support new websites
+  - [x] YiffStar
+  - [ ] [Two Kinds](http://twokinds.keenspot.com/)
+  - [ ] [Slightly damned](http://www.sdamned.com/)
+  - [x] New API on FimFiction.net (faster)
+  - [x] Replace MangaFox which is causing issues all the time
+  - [ ] Others? Any ideas? I'm open for requests
+    - [x] [e-Hentai](https://e-hentai.org/) requested
+    - [x] Find some FR comics/manga websites
+    - [ ] Find more FR thingies
+- [ ] Support videos (anime)?
+- [x] A GUI library
+  - [x] Make one
+  - [x] Make it run when no args passed
+  - [x] Fix the UI, it is ugly
+  - [x] Work on the UI thread is BAD
+  - [x] Allow export
+  - [x] Allow delete/refresh
+  - [x] Show a list of types
+    - [x] ..in the menu
+    - [x] ..as a screen view
+  - [x] options screen
+  - [x] support progress events
+  - [x] Real menus
+    - [x] Store the long lists in [A-B], [BA-BB], ...
+- [ ] A TUI library
+  - [x] Choose an output (Jexer)
+  - [x] Implement it from --set-reader to the actual window
+  - [x] List the stories
+  - [x] Fix the UI layout
+  - [x] Status bar
+  - [x] Real menus
+    - [ ] Store the long lists in [A-B], [BA-BB], ...
+  - [x] Open a story in the reader and/or natively
+  - [ ] Update the screenshots
+  - [ ] Remember the current chapter and current read status of stories
+  - [ ] Support progress events
+  - [x] Add a properties pages
+  - [ ] Deal with comics
+    - [x] properties page
+    - [x] external launcher
+    - [ ] jexer sixels?
+- [ ] Move the GUI parts out of fanfix itself (see fanfix-swing)
+    - [x] Make new project: fanfix-swing
+    - [x] Fix the UI issues we had (UI thread)
+    - [x] Make it able to browse already downloaded stories
+    - [x] Make it able to download stories
+    - [ ] See what config options to use
+    - [ ] Import all previous menus end functions
+    - [ ] Feature parity with original GUI
+- [ ] Move the TUI parts out of fanfix itself
+    - [ ] Make new project
+- [x] Network support
+  - [x] A server that can send the stories
+  - [x] A network implementation of the Library
+  - [x] Write access to the library
+  - [x] Access rights (a simple "key")
+  - [x] More tests, especially with the GUI
+    - [x] ..even more
+  - [x] support progress events
+- [x] Check if it can work on Android
+  - [x] First checks: it should work, but with changes
+  - [x] Adapt work on images :(
+  - [x] Partial/Conditional compilation
+  - [x] APK export
+- [ ] Android
+  - [x] Android support
+  - [x] Show current stories
+  - [x] Download new stories
+  - [ ] Sort stories by Source/Author
+  - [ ] Fix UI
+  - [ ] support progress events
+  - [x] give up and ask a friend...
+- [ ] Translations
+  - [x] i18n system in place
+  - [x] Make use of it in text
+  - [x] Make use of it in gui
+  - [ ] Make use of it in tui
+  - [ ] Use it for all user output
+  - [x] French translation
+  - [x] French manual/readme
+- [x] Install a mechanism to handle stories import/export progress update
+  - [x] Progress system
+  - [x] in support classes (import)
+  - [x] in output classes (export)
+- [x] Version
+  - [x] Use a version number
+  - [x] Show it in UI
+  - [x] A check-update feature
+    - [x] ..translated
+- [ ] Improve GUI library
+    - [x] Allow launching a custom application instead of Desktop.start
+    - [ ] Add the resume next to the cover icon if available (as an option)
+    - [ ] Add the resume in the Properties page (maybe a second tab?)
+- [ ] Bugs
+    - [x] Fix "Redownload also reset the source"
+    - [x] Fix "Redownload remote does not show the new item before restart of client app"
+    - [x] Fix eHentai "content warning" access (see 455)
+    - [ ] Fix the configuration system (for new or changed options, new or changed languages)
+    - [x] remote import also download the file in cache, why?
+    - [x] import file in remote mode tries to import remote file!!
+    - [ ] import file does not find author in cbz with SUMMARY file
+    - [x] import file:// creates a tmp without auto-deletion in /tmp/fanfic-...
+
diff --git a/VERSION b/VERSION
new file mode 100644 (file)
index 0000000..47ae1f7
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+3.1.2-dev
diff --git a/changelog-fr.md b/changelog-fr.md
new file mode 100644 (file)
index 0000000..4633d62
--- /dev/null
@@ -0,0 +1,288 @@
+# Fanfix
+
+# Version WIP
+
+- new: bibliothèque http/https
+
+# Version 3.1.2
+
+- fix: date de publication/création formatée
+- e621: correction sur l'ordre, encore
+- e621: possibilité d'utiliser un Login/API key pour éviter les blacklists
+- e621: meilleures meta-data
+
+# Version 3.1.1
+
+- e621: correction pour les chapitres des pools dans l'ordre inverse
+- fix: trie les histores par nom et plus par date
+- fix: affiche le nom de l'histoire dans les barres de progrès
+
+# Version 3.1.0
+
+- MangaHub: un nouvau site de manga (English)
+- MangaFox: retrait du support (site désagréable)
+- e621: correction pour la cover non sauvée
+
+# Version 3.0.1
+
+- fix: en cas d'URL non supportée, n'affiche plus un message d'erreur relatif à "file://"
+- e621: update pour e621 (et ce n'est plus un BasicSupport_Deprecated)
+
+# Version 3.0.0
+
+- new: maintenant compatible Android (voir [companion project](https://gitlab.com/Rayman22/fanfix-android))
+- new: recherche d'histoires (pas encore toutes les sources)
+- new: support d'un proxy
+- fix: support des CBZ contenant du texte
+- fix: correction de DEBUG=0
+- fix: correction des histoires importées qui n'arrivent pas immédiatement à l'affichage
+- gui: correction pour le focus 
+- gui: fix pour la couleur d'arrière plan
+- gui: fix pour la navigation au clavier (haut et bas)
+- gui: configuration beaucoup plus facile
+- gui: peut maintenant télécharger toutes les histoires d'un groupe en cache en une fois
+- MangaLEL: site web changé
+- search: supporte MangaLEL
+- search: supporte Fanfiction.net
+- FimFictionAPI: correction d'une NPE
+- remote: changement du chiffrement because Google
+- remote: incompatible avec 2.x
+- remote: moins bonnes perfs mais meilleure utilisation de la mémoire
+- remote: le log inclus maintenant la date des évènements
+- remote: le mot de passe se configure maintenant dans le fichier de configuration
+
+# Version 2.0.3
+
+SoFurry: correction pour les histoires disponibles uniquement aux utilisateurs inscrits sur le site
+
+# Version 2.0.2
+
+- i18n: changer la langue dans les options fonctionne aussi quand $LANG existe
+- gui: traduction en français
+- gui: ReDownloader ne supprime plus le livre original
+- fix: corrections pour le visionneur interne
+- fix: quelques corrections pour les traductions
+
+# Version 2.0.1
+
+- core: un changement de titre/source/author n'était pas toujours visible en runtime
+- gui: ne recharger les histoires que quand nécessaire
+
+# Version 2.0.0
+
+- new: les sources peuvent contenir "/" (et utiliseront des sous-répertoires en fonction)
+- gui: nouvelle page pour voir les propriétés d'une histoire
+- gui: renommer les histoires, changer l'auteur
+- gui: permet de lister les auteurs ou les sources en mode "tout" ou "listing"
+- gui: lecteur intégré pour les histoires (texte et images)
+- tui: fonctionne maintenant assez bien que pour être déclaré stable
+- cli: permet maintenant de changer la source, le titre ou l'auteur
+- remote: fix de setSourceCover (ce n'était pas vu par le client)
+- remote: on peut maintenant importer un fichier local
+- remote: meilleures perfs
+- remote: incompatible avec 1.x
+- fix: deadlock dans certains cas rares (nikiroo-utils)
+- fix: le résumé n'était pas visibe dans certains cas
+- fix: update de nikiroo-utils, meilleures perfs pour le remote
+- fix: eHentai content warning
+
+# Version 1.8.1
+
+- e621: les images étaient rangées à l'envers pour les recherches (/post/)
+- e621: correction pour /post/search/
+- remote: correction de certains problèmes de timeout
+- remote: amélioration des perfs
+- fix: permettre les erreurs I/O pour les CBZ (ignore l'image)
+- fix: corriger le répertoire des covers par défaut
+
+# Version 1.8.0
+
+- FimfictionAPI: les noms des chapitres sont maintenant triés correctement
+- e621: supporte aussi les recherches (/post/)
+- remote: la cover est maintenant envoyée au client pour les imports
+- MangaLel: support pour MangaLel
+
+# Version 1.7.1
+
+- GUI: fichiers tmp supprimés trop vite en mode GUI
+- fix: une histoire sans cover pouvait planter le programme
+- ePub: erreur d'import pour un EPUB local sans cover
+
+# Version 1.7.0
+
+- new: utilisation de jsoup pour parser le HTML (pas encore partout)
+- update: mise à jour de nikiroo-utils
+- android: compatibilité Android
+- MangaFox: fix après une mise-à-jour du site
+- MangaFox: l'ordre des tomes n'était pas toujours bon
+- ePub: correction pour la compatibilité avec certains lecteurs ePub
+- remote: correction pour l'utilisation du cache
+- fix: TYPE= était parfois mauvais dans l'info-file
+- fix: les guillemets n'étaient pas toujours bien ordonnés
+- fix: amélioration du lanceur externe (lecteur natif)
+- test: plus de tests unitaires
+- doc: changelog disponible en français
+- doc: man pages (en, fr)
+- doc: SysV init script
+
+# Version 1.6.3
+
+- fix: corrections de bugs
+- remote: notification de l'état de progression des actions
+- remote: possibilité d'envoyer des histoires volumineuses
+- remote: détection de l'état du serveur
+- remote: import and change source on server
+- CBZ: meilleur support de certains CBZ (si SUMMARY ou URL est présent dans le CBZ)
+- Library: correction pour les pages de couvertures qui n'étaient pas toujours effacées quand l'histoire l'était
+- fix: correction pour certains cas où les images ne pouvaient pas être sauvées (quand on demande un jpeg mais que l'image n'est pas supportée, nous essayons maintenant ensuite en png)
+- remote: correction pour certaines images de couvertures qui n'étaient pas trouvées (nikiroo-utils)
+- remote: correction pour les images de couvertures qui n'étaient pas transmises
+
+## Version 1.6.2
+
+- GUI: amélioration des barres de progression
+- GUI: meilleures performances pour l'ouverture d'une histoire si le type de l'histoire est déjà le type demandé pour l'ouverture (CBZ -> CBZ ou HTML -> HTML par exemple)
+
+## Version 1.6.1
+
+- GUI: nouvelle option (désactivée par défaut) pour afficher un élément par source (type) sur la page de démarrage au lieu de tous les éléments triés par source (type)
+- fix: correction de la source (type) qui était remis à zéro après un re-téléchargement
+- GUI: affichage du nombre d'images présentes au lieu du nombre de mots pour les histoires en images
+
+## Version 1.6.0
+
+- TUI: un nouveau TUI (mode texte mais avec des fenêtres et des menus en texte) -- cette option n'est pas compilée par défaut (configure.sh)
+- remote: un serveur pour offrir les histoires téléchargées sur le réseau
+- remote: une Library qui reçoit les histoires depuis un serveur distant
+- update: mise à jour de nikiroo-utils
+- FimFiction: support for the new API
+- new: mise à jour du cache (effacer le cache actuel serait une bonne idée)
+- GUI: correction pour le déplacement d'une histoire qui n'est pas encore dans le cache
+
+## Version 1.5.3
+
+- FimFiction: correction pour les tags dans les metadata et la gestion des chapitres pour certaines histoires
+
+## Version 1.5.2
+
+- FimFiction: correction pour les tags dans les metadata
+
+## Version 1.5.1
+
+- FimFiction: mise à jour pour supporter FimFiction 4
+- eHentai: correction pour quelques metadata qui n'étaient pas reprises
+
+## Version 1.5.0
+
+- eHentai: nouveau site supporté sur demande (n'hésitez pas !) : e-hentai.org
+- Library: amélioration des performances quand on récupère une histoire (la page de couverture n'est plus chargée quand cela n'est pas nécessaire)
+- Library: correction pour les pages de couvertures qui n'étaient pas toujours effacées quand l'histoire l'était
+- GUI: amélioration des performances pour l'affichage des histoires (la page de couverture est re-dimensionnée en cache)
+- GUI: on peut maintenant éditer la source d'une histoire ("Déplacer vers...")
+
+## Version 1.4.2
+
+- GUI: nouveau menu Options pour configurer le programme (très minimaliste pour le moment)
+- new: gestion de la progression des actions plus fluide et avec plus de détails
+- fix: meilleur support des couvertures pour les fichiers en cache
+
+## Version 1.4.1
+
+- fix: correction de UpdateChecker qui affichait les nouveautés de TOUTES les versions du programme au lieu de se limiter aux versions plus récentes
+- fix: correction de la gestion de certains sauts de ligne pour le support HTML (entre autres, FanFiction.net)
+- GUI: les barres de progrès fonctionnent maintenant correctement
+- update: mise à jour de nikiroo-utils pour récupérer toutes les étapes dans les barres de progrès
+- ( --Fin des nouveautés de la version 1.4.1-- )
+
+## Version 1.4.0
+
+- new: sauvegarde du nombre de mots et de la date de création des histoires dans les fichiers mêmes
+- GUI: nouvelle option pour afficher le nombre de mots plutôt que le nom de l'auteur sous le nom de l'histoire
+- CBZ: la première page n'est plus doublée sur les sites n'offrant pas de page de couverture
+- GUI: recherche de mise à jour (le programme cherche maintenant si une mise à jour est disponible pour en informer l'utilisateur)
+
+## Version 1.3.1
+
+- GUI: on peut maintenant trier les histoires par auteur
+
+## Version 1.3.0
+
+- YiffStar: le site YiffStar (SoFurry.com) est maintenant supporté
+- new: support des sites avec login/password
+- GUI: les URLs copiées (ctrl+C) sont maintenant directement proposées par défaut quand on importe une histoire
+- GUI: la version est maintenant visible (elle peut aussi être récupérée avec --version)
+
+## Version 1.2.4
+
+- GUI: nouvelle option re-télécharger
+- GUI: les histoires sont maintenant triées (et ne changeront plus d'ordre après chaque re-téléchargement)
+- fix: corrections sur l'utilisation des guillemets
+- fix: corrections sur la détection des chapitres
+- new: de nouveaux tests unitaires
+
+## Version 1.2.3
+
+- HTML: les fichiers originaux (info_text) sont maintenant rajoutés quand on sauve
+- HTML: support d'un nouveau type de fichiers à l'import: HTML (si fait par Fanfix)
+
+## Version 1.2.2
+
+- GUI: nouvelle option "Sauver sous..."
+- GUI: corrections (rafraîchissement des icônes)
+- fix: correction de la gestion du caractère TAB dans les messages utilisateurs
+- GUI: LocalReader supporte maintenant "--read"
+- ePub: corrections sur le CSS
+
+## Version 1.2.1
+
+- GUI: de nouvelles fonctions ont été ajoutées dans le menu
+- GUI: popup avec un clic droit sur les histoires
+- GUI: corrections, particulièrement pour LocalLibrary
+- GUI: nouvelle icône (un rond vert) pour dénoter qu'une histoire est "cachée" (dans le LocalReader)
+
+## Version 1.2.0
+
+- GUI: système de notification de la progression des actions
+- ePub: changements sur le CSS
+- new: de nouveaux tests unitaires
+- GUI: de nouvelles fonctions ont été ajoutées dans le menu (supprimer, rafraîchir, un bouton exporter qui ne fonctionne pas encore)
+
+## Version 1.1.0
+
+- CLI: nouveau système de notification de la progression des actions
+- e621: correction pour les "pending pools" qui ne fonctionnaient pas avant
+- new: système de tests unitaires ajouté (pas encore de tests propres à Fanfix)
+
+## Version 1.0.0
+
+- GUI: état acceptable pour une 1.0.0 (l'export n'est encore disponible qu'en CLI)
+- fix: bugs fixés
+- GUI: (forte) amélioration
+- new: niveau fonctionnel acceptable pour une 1.0.0
+
+## Version 0.9.5
+
+- fix: bugs fixés
+- new: compatibilité avec WIN32 (testé sur Windows 10)
+
+## Version 0.9.4
+
+- fix: (beaucoup de) bugs fixés
+- new: amélioration des performances
+- new: moins de fichiers cache utilisés
+- GUI: amélioration (pas encore parfait, mais utilisable)
+
+## Version 0.9.3
+
+- fix: (beaucoup de) bugs fixés
+- GUI: première implémentation graphique (laide et buggée)
+
+## Version 0.9.2
+
+- new: version minimum de la JVM : Java 1.6 (tous les JAR binaires ont été compilés en Java 1.6)
+- fix: bugs fixés
+
+## Version 0.9.1
+
+- version initiale
+
diff --git a/changelog.md b/changelog.md
new file mode 100644 (file)
index 0000000..1a8630d
--- /dev/null
@@ -0,0 +1,288 @@
+# Fanfix
+
+# Version WIP
+
+- new: http/https remote library
+
+# Version 3.1.2
+
+- fix: publication/creation date formated 
+- e621: fix order again
+- e621: option to use a Login/API key to evade blacklists
+- e621: better metadata
+
+# Version 3.1.1
+
+- e621: fix chapters in reverse order for pools
+- fix: sort the stories by title and not by date
+- fix: expose the story name on progress bars
+
+# Version 3.1.0
+
+- MangaHub: a manga website (English)
+- MangaFox: removed (too many unfriendly changes, bye)
+- e621: fix cover not saved 
+
+# Version 3.0.1
+
+- fix: update for e621 (and it is no more a BasicSupport_Deprecated)
+- fix: for unsupported URL, do not errors out with a "file://" related message
+
+# Version 3.0.0
+
+- new: now Android-compatible (see [companion project](https://gitlab.com/Rayman22/fanfix-android))
+- new: story search (not all sources yet)
+- new: proxy support
+- fix: support hybrid CBZ (with text)
+- fix: fix DEBUG=0
+- fix: fix imported stories that don't immediatly appear on screen
+- gui: focus fix
+- gui: bg colour fix
+- gui: fix keyboard navigation support (up and down)
+- gui: configuration is now much easier
+- gui: can now prefetch to cache all the sories of a group at once
+- MangaLEL: website has changed
+- search: Fanfiction.net support
+- search: MangaLEL support
+- FimFictionAPI: fix NPE
+- remote: encryption mode changed because Google
+- remote: not compatible with 2.x
+- remote: can now use password from config file
+- remote: worse perfs but much better memory usage
+- remote: log now includes the time of events
+
+# Version 2.0.3
+
+SoFurry: fix for stories only available to registrated users
+
+# Version 2.0.2
+
+- i18n: setting the language in the option panel now works even with $LANG set
+- gui: translated into French
+- gui: ReDownload does not delete the original book anymore
+- gui: internal viewer fixes 
+- gui: some translation fixes
+
+# Version 2.0.1
+
+- core: a change of title/source/author was not always visible at runtime
+- gui: only reload the stoies when needed
+
+# Version 2.0.0
+
+- new: sources can contain "/" (and will use subdirectories)
+- gui: new Properties page for stories
+- gui: rename stories, change author
+- gui: allow "all" and "listing" modes for sources and authors
+- gui: integrated viewer for stories (both text and images)
+- tui: now working well enough to be considered stable
+- cli: now allow changing the source, title and author
+- remote: fix setSourceCover (was not seen by client)
+- remote: can now import local files into a remote library
+- remote: better perfs
+- remote: not compatible with 1.x
+- fix: deadlock in some rare cases (nikiroo-utils)
+- fix: the resume was not visible in some cases
+- fix: update nikiroo-utils, better remote perfs
+- fix: eHentai content warning
+
+# Version 1.8.1
+
+- e621: the images were stored in reverse order for searches (/post/)
+- e621: fix for /post/search/
+- remote: fix some timeout issues
+- remote: improve perfs
+- fix: allow I/O errors on CBZ files (skip image)
+- fix: fix the default covers directory
+
+# Version 1.8.0
+
+- FimfictionAPI: chapter names are now correctly ordered
+- e621: now supports searches (/post/)
+- remote: cover is now sent over the network for imported stories
+- MangaLel: new support for MangaLel
+
+# Version 1.7.1
+
+- GUI: tmp files deleted too soon in GUI mode
+- fix: unavailable cover could cause a crash
+- ePub: local EPUB import error when no cover
+
+# Version 1.7.0
+
+- new: use jsoup for parsing HTML (not everywhere yet)
+- update nikiroo-utils
+- android: Android compatibility
+- MangaFox: fix after website update
+- MangaFox: tomes order was not always correct
+- ePub: fix for compatibility with some ePub viewers
+- remote: cache usage fix
+- fix: TYPE= not always correct in info-file
+- fix: quotes error
+- fix: improve external launcher (native viewer)
+- test: more unit tests
+- doc: changelog available in French
+- doc: man pages (en, fr)
+- doc: SysV init script
+
+# Version 1.6.3
+
+- fix: bug fixes
+- remote: progress report
+- remote: can send large files
+- remote: detect server state
+- remote: import and change source on server
+- CBZ: better support for some CBZ files (if SUMMARY or URL files are present in it)
+- Library: fix cover images not deleted on story delete
+- fix: some images not supported because not jpeg-able (now try again in png)
+- remote: fix some covers not found over the wire (nikiroo-utils)
+- remote: fix cover image files not sent over the wire
+
+## Version 1.6.2
+
+- GUI: better progress bars
+- GUI: can now open files much quicker if they are stored in both library and cache with the same output type 
+
+## Version 1.6.1
+
+- GUI: new option (disabled by default) to show one item per source type instead of one item per story when showing ALL sources (which is also the start page)
+- fix: source/type reset when redownloading
+- GUI: show the number of images instead of the number of words for images documents 
+
+## Version 1.6.0
+
+- TUI: new TUI (text with windows and menus) -- not compiled by default (configure.sh)
+- remote: a server option to offer stories on the network
+- remote: a remote library to get said stories from the network
+- update to latest version of nikiroo-utils
+- FimFiction: support for the new API
+- new: cache update (you may want to clear your current cache)
+- GUI: bug fixed (moving an unopened book does not fail any more)
+
+## Version 1.5.3
+
+- FimFiction: Fix tags and chapter handling for some stories
+
+## Version 1.5.2
+
+- FimFiction: Fix tags metadata on FimFiction 4
+
+## Version 1.5.1
+
+- FimFiction: Update to FimFiction 4
+- eHentai: Fix some meta data that were missing
+
+## Version 1.5.0
+
+- eHentai: new website supported on request (do not hesitate!): e-hentai.org
+- Library: perf improvement when retrieving the stories (cover not loaded when not needed)
+- Library: fix the covers that were not always removed when deleting a story
+- GUI: perf improvement when displaying books (cover resized then cached)
+- GUI: sources are now editable ("Move to...")
+
+## Version 1.4.2
+
+- GUI: new Options menu to configure the program (minimalist for now)
+- new: improve progress reporting (smoother updates, more details)
+- fix: better cover support for local files
+
+## Version 1.4.1
+
+- fix: UpdateChecker which showed the changes of ALL versions instead of the newer ones only
+- fix: some bad line breaks on HTML support (including FanFiction.net)
+- GUI: progress bar now working correctly
+- update: nikiroo-utils update to show all steps in the progress bars
+- ( --End of changes for version 1.4.1-- )
+
+## Version 1.4.0
+
+- new: remember the word count and the date of creation of Fanfix stories
+- GUI: option to show the word count instead of the author below the book title
+- CBZ: do not include the first page twice anymore for no-cover websites
+- GUI: update version check (we now check for new versions)
+
+## Version 1.3.1
+
+- GUI: can now display books by Author
+
+## Version 1.3.0
+
+- YiffStar: YiffStar (SoFurry.com) is now supported
+- new: supports login/password websites
+- GUI: copied URLs (ctrl+C) are selected by default when importing a URL
+- GUI: version now visible (also with --version)
+
+## Version 1.2.4
+
+- GUI: new option: Re-download
+- GUI: books are now sorted (will not jump around after refresh/redownload)
+- fix: quote character handling
+- fix: chapter detection
+- new: more tests included
+
+## Version 1.2.3
+
+- HTML: include the original (info_text) files when saving
+- HTML: new input type supported: HTML files made by Fanfix
+
+## Version 1.2.2
+
+- GUI: new "Save as..." option
+- GUI: fixes (icon refresh)
+- fix: handling of TABs in user messages
+- GUI: LocalReader can now be used with --read
+- ePub: CSS style fixes
+
+## Version 1.2.1
+
+- GUI: some menu functions added
+- GUI: right-click popup menu added
+- GUI: fixes, especially for the LocalReader library
+- GUI: new green round icon to denote "cached" (into LocalReader library) files
+
+## Version 1.2.0
+
+- GUI: progress reporting system
+- ePub: CSS style changes
+- new: unit tests added
+- GUI: some menu functions added (delete, refresh, a place-holder for export)
+
+## Version 1.1.0
+
+- CLI: new Progress reporting system
+- e621: fix on "pending" pools, which were not downloaded before
+- new: unit tests system added (but no test yet, as all tests were moved into nikiroo-utils)
+
+## Version 1.0.0
+
+- GUI: it is now good enough to be released (export is still CLI-only though)
+- fix: bug fixes
+- GUI: improved (a lot)
+- new: should be good enough for 1.0.0
+
+## Version 0.9.5
+
+- fix: bug fixes
+- new: WIN32 compatibility (tested on Windows 10)
+
+## Version 0.9.4
+
+- fix: bug fixes (lots of)
+- new: perf improved
+- new: use less cache files
+- GUI: improvement (still not really OK, but OK enough I guess)
+
+## Version 0.9.3
+
+- fix: bug fixes (lots of)
+- GUI: first implementation (which is ugly and buggy -- the buggly GUI)
+
+## Version 0.9.2
+
+- new: minimum JVM version: Java 1.6 (all binary JAR files will be released in 1.6)
+- fix: bug fixes
+
+## Version 0.9.1
+
+- initial version
+
diff --git a/configure.sh b/configure.sh
new file mode 100755 (executable)
index 0000000..1c1a529
--- /dev/null
@@ -0,0 +1,71 @@
+#!/bin/sh
+
+# default:
+PREFIX=/usr/local
+PROGS="java javac jar make sed"
+
+IMG=be/nikiroo/utils/ui/ImageUtilsAwt
+
+valid=true
+while [ "$*" != "" ]; do
+       key=`echo "$1" | cut -f1 -d=`
+       val=`echo "$1" | cut -f2 -d=`
+       case "$key" in
+       --)
+       ;;
+       --help) #               This help message
+               echo The following arguments can be used:
+               cat "$0" | grep '^\s*--' | grep '#' | while read ln; do
+                       cmd=`echo "$ln" | cut -f1 -d')'`
+                       msg=`echo "$ln" | cut -f2 -d'#'`
+                       echo "  $cmd$msg"
+               done
+       ;;
+       --prefix) #=PATH        Change the prefix to the given path
+               PREFIX="$val"
+       ;;
+       *)
+               echo "Unsupported parameter: '$1'" >&2
+               echo >&2
+               sh "$0" --help >&2
+               valid=false
+       ;;
+       esac
+       shift
+done
+
+[ $valid = false ] && exit 1
+
+MESS="A required program cannot be found:"
+for prog in $PROGS; do
+       out="`whereis -b "$prog" 2>/dev/null`"
+       if [ "$out" = "$prog:" ]; then
+               echo "$MESS $prog" >&2
+               valid=false
+       fi
+done
+
+[ $valid = false ] && exit 2
+
+if [ "`whereis tput`" = "tput:" ]; then
+       ok='"[ ok ]"';
+       ko='"[ !! ]"';
+       cols=80;
+else
+       #ok='"`tput bold`[`tput setf 2` OK `tput init``tput bold`]`tput init`"';
+       #ko='"`tput bold`[`tput setf 4` !! `tput init``tput bold`]`tput init`"';
+       ok='"`tput bold`[`tput setaf 2` OK `tput init``tput bold`]`tput init`"';
+       ko='"`tput bold`[`tput setaf 1` !! `tput init``tput bold`]`tput init`"';
+       cols='"`tput cols`"';
+fi;
+
+echo "MAIN = be/nikiroo/fanfix/Main" > Makefile
+echo "MORE = $IMG" >> Makefile
+echo "TEST = be/nikiroo/fanfix/test/Test" >> Makefile
+echo "TEST_PARAMS = $cols $ok $ko" >> Makefile
+echo "NAME = fanfix" >> Makefile
+echo "PREFIX = $PREFIX" >> Makefile
+echo "JAR_FLAGS += -C bin/ org -C bin/ be -C ./ LICENSE -C ./ VERSION -C libs/ licenses" >> Makefile
+
+cat Makefile.base >> Makefile
+
diff --git a/derename.sh b/derename.sh
new file mode 100755 (executable)
index 0000000..6c8cbff
--- /dev/null
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+git status | grep renamed: | sed 's/[^:]*: *\([^>]*\) -> \(.*\)/\1>\2/g' | while read -r ln; do
+       old="`echo "$ln" | cut -f1 -d'>'`"
+       new="`echo "$ln" | cut -f2 -d'>'`"
+       mkdir -p "`dirname "$old"`"
+       git mv "$new" "$old"
+       rmdir "`dirname "$new"`" 2>/dev/null
+       true
+done
+
diff --git a/docs/android/android.md b/docs/android/android.md
new file mode 100644 (file)
index 0000000..f3d2775
--- /dev/null
@@ -0,0 +1,289 @@
+# Android UI mock-up
+
+## Concepts
+
+### Story
+
+We have **Stories** in Fanfix, which represent a story (an "epub", ...) or a comics (a "CBZ" file, ...).
+
+A story is written by an author, comes from a source and is dentified by a LUID (Local Unique ID).
+It can be a text story or an image story.
+
+The source can actually be changed by the user (this is the main sorting key).
+
+### Book
+
+We also have **Books**.
+
+Books can be used to display:
+
+- All the sources present in the library
+- All the authors known in the lbrary
+- Stories sharing a source or an author
+
+### All and Listing modes
+
+When representing sources or authors, books can be arranged in two modes:
+
+- "All" mode     : all the sources/authors are represented by a book and displayed in the UI
+- "Listing" mode : for each source/author, a group of books representing the corresponding story is present with the name of the source/author to group them (for instance, a JLabel on top of the group)
+
+Note: Listing mode can be left out of the Android version if needed (but the all mode is really needed).
+
+### Destination
+
+What I call here a destination is a specific group of books.
+
+Examples :
+
+- All the sources
+- All the books of author "Tulipe, F."
+- A listing of all the authors and their stories
+
+## Core
+
+### Library (main screen)
+
+![Main library](screens/main_lib.jpg)
+
+#### Header
+
+The header has a title, a navigation icon on the left and a search icon.
+
+Title can vary upon the current displayed books:
+
+- All sources
+- Sources listing
+- Source: xxx
+- All authors
+- Authors listing
+- Author: xxx
+
+The navigation icon open the Navigation drawer.
+
+##### Search
+
+![Search/Filter](screens/search.jpg)
+
+The search icon is actually a filter: it will hide all the books that don't contain the given text (search on LUID, title and author).
+
+#### List
+
+This list will hold books. Each item will be represented by :
+
+- a cover image (which is provided by fanfix.jar)
+- a main info, which can be:
+  - for stories, the story title
+  - for source books, the source name
+  - for author books, the author name
+- a secondary info, which can vary via a setting (see the Options page) between:
+  - author name (for a book representing an author, it is left blank)
+  - a count (a word count for text stories, an image count for images stories, a stories count for sources and authors books)
+
+#### UI
+
+Material.IO:
+
+- Title, navigation icon, search icon : [App bar top](https://material.io/design/components/app-bars-top.html)
+- List                                : [Cards](https://material.io/design/components/cards.html)
+
+A tap will open the target book in full-screen mode (i.e., the details about the card).
+
+On the detailed card, you will see the description (see Description Page) and 3 buttons :
+
+- Open
+- Delete
+- "..." for a menu
+
+### Navigation drawer
+
+![Navigation Drawer](screens/navigation.jpg)
+
+The navigation drawer will list 4 destinations:
+
+- All the sources
+- Listing of the sources
+- All the authors
+- Listing of the authors
+- By source
+
+...and 2 foldable "panels" with more destinations:
+
+- By source
+- By author
+
+Those subpanels will either contain the sources/authors **or** sub-subpanels with sources/authors.
+See fanfix.jar (BasicLibrary.getSourcesGrouped() and BasicLibrary.getAuthorsGrouped()).
+
+Note: if those last two cause problems, they can be removed; the first four options would be enough to cover the main use cases.
+
+#### UI
+
+Material.IO:
+
+- Navigation drawer: navigation drawer
+
+### Context menu
+
+![Context Menu](screens/menu.jpg)
+
+The context menu options are as follow for stories:
+
+- Open                : open the book (= internal or external reader)
+- Rename...           : ask the user for a new title for the story (default is current name)
+- Move to >>          : show a "new source..." option as well as the current ones fo quick select (BasicLibrary.getSourcesGrouped() will return all the sources on only one level if their number is small enough)
+  - *
+    - [new source...]
+  - [A-H]
+    - Anima
+    - Fanfiction.NET
+  - [I-Z]
+    - MangaFox
+- Change author to >> : show a "new author..." option as well as the current ones fo quick select (BasicLibrary.getAuthorsGrouped() will return all the authors on only one level if their number is small enough)
+  - *
+    - [new author...]
+  - [0-9]
+    - 5-stars
+  - [A-H]
+    - Albert
+    - Béatrice
+    - Hakan
+  - [I-Z]
+    - Irma
+    - Zoul
+- Delete              : delete the story
+- Redownload          : redownload the story (will **not** delete the original)
+- Properties          : open the properties page
+
+For other books (sources and authors):
+
+- Open: open the book (= go to the selected destination)
+
+#### UI
+
+Material.IO:
+
+- menu: [menu](https://developer.android.com/guide/topics/ui/menus.html)
+
+The menu will NOT use sublevels but link to a [list](https://material.io/design/components/lists.html) instead.
+
+### Description page
+
+![Description Page](screens/desc.jpg)
+
+#### Header
+
+Use the same cover image as the books, and the description key/values comes from BasicReader.getMetaDesc(MetaData).
+
+#### Description
+
+Simply display Story.getMeta().getResume(), without adding the chapter number (it is always 0 for description).
+
+An example can be seen in be.nikiroo.fanfix.ui.GuiReaderViewerTextOutput.java.
+
+### Options page
+
+![Options Page](screens/options.jpg)
+
+It consists of a "Remote Library" panel:
+
+- enable : an option to enable/disable the remote library (if disabled, use the local library instead)
+- server : (only enabled if the remote library is) the remote server host
+- port   : (only enabled if the remote library is) the remote server port
+- key    : (only enabled if the remote library is) the remote server secret key
+
+...and 5 other options:
+
+- Open CBZ files internally    : open CBZ files with the internal viewer
+- Open epub files internally   : open EPUB files with the internal viewer
+- Show handles on image viewer : can hide the handles used as cues in the image viewer to know where to click
+- Startup screen               : select the destination to show on application startup
+- Language                     : select the language to use
+
+#### Startup screen
+
+Can be:
+
+- Sources
+  - All
+  - Listing
+- Authors
+  - All
+  - Listing
+
+...but will have to be presented in a better way to the user (i.e., better names).
+
+#### UI
+
+Material.IO:
+
+- the page itself     : Temporary UI Region
+- the options         : Switch
+- the languages       : Exposed Dropdown Menu
+- the text fields     : the default for text fields
+- the scret key field : the default for passwords (with * * * )
+
+## Internal viewer
+
+The program will have an internal viewer that will be able to display the 2 kinds of stories (images and text).
+
+### Base viewer
+
+This is common to both of the viewer (this is **not** an architectural directives, I only speak about the concept here).
+
+![Base Viewer](screens/viewer.jpg)
+
+#### Header
+
+The title is the title of the story, shortened with "..." if too long.
+
+#### Content
+
+This area will host the text viewer or the image viewer.
+
+#### Navigator
+
+It contains 4 action buttons (first, previous, next and last chapter) and the title of the current chapter:
+
+- Descripton         : for the properties page (same layout as the actual Properties page)
+- Chapter X/Y: title : for the normal chapters (note that "Chapter X/Y" should be bold, and "X" should be coloured)
+
+#### UI
+
+Matrial.IO:
+
+- Header    : Header
+- Navigator : [Sheets bottom](https://material.io/design/components/sheets-bottom.html)
+
+### Text viewer
+
+![Text Viewer](screens/viewer-text.jpg)
+
+It will contain the content of the current chapter (Story.getChapters().get(index - 1)).
+
+Same layout as the Properties page uses for the resume, with just a small difference: the chapter name is now prefixed by "Chaper X: ".
+
+### Image viewer
+
+![Image Viewer](screens/viewer-image.jpg)
+
+#### Image
+
+Auto-zoom and fit (keep aspect ratio).
+
+#### Image counter
+
+Just display "Image X/Y"
+
+#### Handles
+
+This is a simple cue to show the user where to click.
+
+It can be hidden via the option "Show handles on image viewer" from the Options page.
+
+#### UI
+
+Pinch & Zoom should be allowed.
+
+Drag-to-pan should be allowed.
+
diff --git a/docs/android/screens/desc.jpg b/docs/android/screens/desc.jpg
new file mode 100755 (executable)
index 0000000..766b746
Binary files /dev/null and b/docs/android/screens/desc.jpg differ
diff --git a/docs/android/screens/main_lib.jpg b/docs/android/screens/main_lib.jpg
new file mode 100755 (executable)
index 0000000..e105824
Binary files /dev/null and b/docs/android/screens/main_lib.jpg differ
diff --git a/docs/android/screens/menu.jpg b/docs/android/screens/menu.jpg
new file mode 100755 (executable)
index 0000000..ea67163
Binary files /dev/null and b/docs/android/screens/menu.jpg differ
diff --git a/docs/android/screens/navigation.jpg b/docs/android/screens/navigation.jpg
new file mode 100755 (executable)
index 0000000..997cb32
Binary files /dev/null and b/docs/android/screens/navigation.jpg differ
diff --git a/docs/android/screens/options.jpg b/docs/android/screens/options.jpg
new file mode 100755 (executable)
index 0000000..b4ad836
Binary files /dev/null and b/docs/android/screens/options.jpg differ
diff --git a/docs/android/screens/search.jpg b/docs/android/screens/search.jpg
new file mode 100755 (executable)
index 0000000..f32257e
Binary files /dev/null and b/docs/android/screens/search.jpg differ
diff --git a/docs/android/screens/viewer-image.jpg b/docs/android/screens/viewer-image.jpg
new file mode 100755 (executable)
index 0000000..8f5c742
Binary files /dev/null and b/docs/android/screens/viewer-image.jpg differ
diff --git a/docs/android/screens/viewer-text.jpg b/docs/android/screens/viewer-text.jpg
new file mode 100755 (executable)
index 0000000..574f688
Binary files /dev/null and b/docs/android/screens/viewer-text.jpg differ
diff --git a/docs/android/screens/viewer.jpg b/docs/android/screens/viewer.jpg
new file mode 100755 (executable)
index 0000000..d8b130a
Binary files /dev/null and b/docs/android/screens/viewer.jpg differ
diff --git a/fanfix.sysv b/fanfix.sysv
new file mode 100755 (executable)
index 0000000..5ab6912
--- /dev/null
@@ -0,0 +1,91 @@
+#!/bin/sh
+#
+# fanfix       This starts the Fanfix remote service.
+#
+# description: Starts the Fanfix remote service
+#
+### BEGIN INIT INFO
+# Default-Start:  3 4 5
+# Short-Description: Fanfix service
+# Description: Starts the Fanfix remote service
+### END INIT INFO
+
+ENABLED=true
+USER=fanfix
+JAR=/path/to/fanfix.jar
+
+FPID=/tmp/fanfix.pid
+OUT=/var/log/fanfix
+ERR=/var/log/fanfix.err
+
+if [ "$ENABLED" != true ]; then
+       [ "$1" != status ]
+       exit $?
+fi
+
+if [ ! -e "$JAR" ]; then
+       echo "Canot find main jar file: $JAR" >&2
+       exit 4
+fi
+
+case "$1" in
+start)
+       if sh "$0" status --quiet; then
+               echo "Fanfix is already running." >&2
+               false
+       else
+               [ -e "$OUT" ] && mv "$OUT" "$OUT".previous
+               [ -e "$ERR" ] && mv "$ERR" "$ERR".previous
+               sudo -u "$USER" -- java -jar "$JAR" --server > "$OUT" 2> "$ERR" &
+               echo $! > "$FPID"
+       fi
+       
+       sleep 0.1
+       sh "$0" status --quiet
+;;
+stop)
+       if sh "$0" status --quiet; then
+               sudo -u "$USER" -- java -jar "$JAR" --stop-server
+       fi
+       
+       i=1
+       while [ $i -lt 100 ]; do
+               if sh "$0" status --quiet; then
+                       echo -n . >&2
+                       sleep 1
+               fi
+               i=`expr $i + 1`
+       done
+       echo >&2
+       
+       if sh "$0" status --quiet; then
+               echo "Process not responding, killing it..." >&2
+               kill "`cat "$FPID"`"
+               sleep 10
+               kill -9 "`cat "$FPID"`" 2>/dev/null
+       fi
+       
+       rm -f "$FPID"
+;;
+restart)
+       sh "$0" stop
+       sh "$0" start
+;;
+status)
+       if [ -e "$FPID" ]; then
+               if [ "$2" = "--quiet" ]; then
+                       ps "`cat "$FPID"`" >/dev/null
+               else
+                       ps "`cat "$FPID"`" >/dev/null \
+                               && echo service is running >&2
+               fi
+       else
+               false
+       fi
+;;
+*)
+       echo $"Usage: $0 {start|stop|status|restart}" >&2
+       false
+;;
+esac
+
diff --git a/icons/fanfix-alt.png b/icons/fanfix-alt.png
new file mode 100644 (file)
index 0000000..4ab0957
Binary files /dev/null and b/icons/fanfix-alt.png differ
diff --git a/icons/fanfix.png b/icons/fanfix.png
new file mode 100644 (file)
index 0000000..983b344
Binary files /dev/null and b/icons/fanfix.png differ
diff --git a/icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png b/icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png
new file mode 100644 (file)
index 0000000..1798dd3
Binary files /dev/null and b/icons/mlpfim-icons.deviantart.com/janswer.deviantart.com/fanfix-d.png differ
diff --git a/icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png b/icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png
new file mode 100644 (file)
index 0000000..fb6fe0d
Binary files /dev/null and b/icons/mlpfim-icons.deviantart.com/laceofthemoon.deviantart.com/fanfix-e.png differ
diff --git a/icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png b/icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png
new file mode 100644 (file)
index 0000000..a56a4d2
Binary files /dev/null and b/icons/mlpfim-icons.deviantart.com/pink618.deviantart.com/fanfix-c.png differ
diff --git a/libs/JSON-java-20190722-sources.jar b/libs/JSON-java-20190722-sources.jar
new file mode 100644 (file)
index 0000000..22a416d
Binary files /dev/null and b/libs/JSON-java-20190722-sources.jar differ
diff --git a/libs/jexer-0.0.4_README.md b/libs/jexer-0.0.4_README.md
new file mode 100644 (file)
index 0000000..7cfe9b4
--- /dev/null
@@ -0,0 +1,220 @@
+Jexer - Java Text User Interface library
+========================================
+
+This library implements a text-based windowing system reminiscient of
+Borland's [Turbo Vision](http://en.wikipedia.org/wiki/Turbo_Vision)
+system.  (For those wishing to use the actual C++ Turbo Vision
+library, see [Sergio Sigala's C++ version based on the public domain
+sources released by Borland.](http://tvision.sourceforge.net/) )
+
+Jexer currently supports three backends:
+
+* System.in/out to a command-line ECMA-48 / ANSI X3.64 type terminal
+  (tested on Linux + xterm).  I/O is handled through terminal escape
+  sequences generated by the library itself: ncurses is not required
+  or linked to.  xterm mouse tracking using UTF8 and SGR coordinates
+  are supported.  For the demo application, this is the default
+  backend on non-Windows/non-Mac platforms.
+
+* The same command-line ECMA-48 / ANSI X3.64 type terminal as above,
+  but to any general InputStream/OutputStream or Reader/Writer.  See
+  the file jexer.demos.Demo2 for an example of running the demo over a
+  TCP socket.  jexer.demos.Demo3 demonstrates how one might use a
+  character encoding than the default UTF-8.
+
+* Java Swing UI.  This backend can be selected by setting
+  jexer.Swing=true.  The default window size for Swing is 80x25, which
+  is set in jexer.session.SwingSession.  For the demo application,
+  this is the default backend on Windows and Mac platforms.
+
+Additional backends can be created by subclassing
+jexer.backend.Backend and passing it into the TApplication
+constructor.
+
+The Jexer homepage, which includes additional information and binary
+release downloads, is at: https://jexer.sourceforge.io .  The Jexer
+source code is hosted at: https://github.com/klamonte/jexer .
+
+
+
+License
+-------
+
+This project is licensed under the MIT License.  See the file LICENSE
+for the full license text.
+
+
+
+Acknowledgements
+----------------
+
+Jexer makes use of the Terminus TrueType font [made available
+here](http://files.ax86.net/terminus-ttf/) .
+
+
+
+Usage
+-----
+
+Simply subclass TApplication and then run it in a new thread:
+
+```Java
+import jexer.*;
+
+class MyApplication extends TApplication {
+
+    public MyApplication() throws Exception {
+        super(BackendType.SWING); // Could also use BackendType.XTERM
+
+        // Create standard menus for File and Window
+        addFileMenu();
+        addWindowMenu();
+
+        // Add a custom window, see below for its code.
+        addWindow(new MyWindow(this));
+    }
+
+    public static void main(String [] args) {
+        try {
+            MyApplication app = new MyApplication();
+            (new Thread(app)).start();
+        } catch (Throwable t) {
+            t.printStackTrace();
+        }
+    }
+}
+```
+
+Similarly, subclass TWindow and add some widgets:
+
+```Java
+class MyWindow extends TWindow {
+
+    public MyWindow(TApplication application) {
+        // See TWindow's API for several constructors.  This one uses the
+        // application, title, width, and height.  Note that the window width
+        // and height include the borders.  The widgets inside the window
+        // will see (0, 0) as the top-left corner inside the borders,
+        // i.e. what the window would see as (1, 1).
+        super(application, "My Window", 30, 20);
+
+        // See TWidget's API for convenience methods to add various kinds of
+        // widgets.  Note that ANY widget can be a container for other
+        // widgets: TRadioGroup for example has TRadioButtons as child
+        // widgets.
+
+        // We will add a basic label, text entry field, and button.
+        addLabel("This is a label", 5, 3);
+        addField(5, 5, 20, false, "enter text here");
+        // For the button, we will pop up a message box if the user presses
+        // it.
+        addButton("Press &Me!", 5, 8, new TAction() {
+            public void DO() {
+                MyWindow.this.messageBox("Box Title", "You pressed me, yay!");
+            }
+        } );
+    }
+}
+```
+
+Put these into a file, compile it with jexer.jar in the classpath, run
+it and you'll see an application like this:
+
+![The Example Code Above](/screenshots/readme_application.png?raw=true "The application in the text of README.md")
+
+See the files in jexer.demos for many more detailed examples showing
+all of the existing UI controls.  The demo can be run in three
+different ways:
+
+  * 'java -jar jexer.jar' .  This will use System.in/out with
+    xterm-like sequences on non-Windows platforms.  On Windows it will
+    use a Swing JFrame.
+
+  * 'java -Djexer.Swing=true -jar jexer.jar' .  This will always use
+    Swing on any platform.
+
+  * 'java -cp jexer.jar jexer.demos.Demo2 PORT' (where PORT is a
+    number to run the TCP daemon on).  This will use the telnet
+    protocol to establish an 8-bit clean channel and be aware of
+    screen size changes.
+
+
+
+More Screenshots
+----------------
+
+![Several Windows Open Including A Terminal](/screenshots/screenshot1.png?raw=true "Several Windows Open Including A Terminal")
+
+![Yo Dawg...](/screenshots/yodawg.png?raw=true "Yo Dawg, I heard you like text windowing systems, so I ran a text windowing system inside your text windowing system so you can have a terminal in your terminal.")
+
+
+
+System Properties
+-----------------
+
+The following properties control features of Jexer:
+
+  jexer.Swing
+  -----------
+
+  Used only by jexer.demos.Demo1.  If true, use the Swing interface
+  for the demo application.  Default: true on Windows platforms
+  (os.name starts with "Windows"), false on non-Windows platforms.
+
+  jexer.Swing.cursorStyle
+  -----------------------
+
+  Used by jexer.io.SwingScreen.  Selects the cursor style to draw.
+  Valid values are: underline, block, outline.  Default: underline.
+
+
+
+Known Issues / Arbitrary Decisions
+----------------------------------
+
+Some arbitrary design decisions had to be made when either the
+obviously expected behavior did not happen or when a specification was
+ambiguous.  This section describes such issues.
+
+  - See jexer.tterminal.ECMA48 for more specifics of terminal
+    emulation limitations.
+
+  - TTerminalWindow uses cmd.exe on Windows.  Output will not be seen
+    until enter is pressed, due to cmd.exe's use of line-oriented
+    input (see the ENABLE_LINE_INPUT flag for GetConsoleMode() and
+    SetConsoleMode()).
+
+  - TTerminalWindow launches 'script -fqe /dev/null' or 'script -q -F
+    /dev/null' on non-Windows platforms.  This is a workaround for the
+    C library behavior of checking for a tty: script launches $SHELL
+    in a pseudo-tty.  This works on Linux and Mac but might not on
+    other Posix-y platforms.
+
+  - Closing a TTerminalWindow without exiting the process inside it
+    may result in a zombie 'script' process.
+
+  - Java's InputStreamReader as used by the ECMA48 backend requires a
+    valid UTF-8 stream.  The default X10 encoding for mouse
+    coordinates outside (160,94) can corrupt that stream, at best
+    putting garbage keyboard events in the input queue but at worst
+    causing the backend reader thread to throw an Exception and exit
+    and make the entire UI unusable.  Mouse support therefore requires
+    a terminal that can deliver either UTF-8 coordinates (1005 mode)
+    or SGR coordinates (1006 mode).  Most modern terminals can do
+    this.
+
+  - jexer.session.TTYSession calls 'stty size' once every second to
+    check the current window size, performing the same function as
+    ioctl(TIOCGWINSZ) but without requiring a native library.
+
+  - jexer.io.ECMA48Terminal calls 'stty' to perform the equivalent of
+    cfmakeraw() when using System.in/out.  System.out is also
+    (blindly!)  put in 'stty sane cooked' mode when exiting.
+
+
+
+Roadmap
+-------
+
+Many tasks remain before calling this version 1.0.  See docs/TODO.md
+for the complete list of tasks.
diff --git a/libs/jsoup-1.10.3-sources.jar b/libs/jsoup-1.10.3-sources.jar
new file mode 100644 (file)
index 0000000..1fe0db4
Binary files /dev/null and b/libs/jsoup-1.10.3-sources.jar differ
diff --git a/libs/licenses/JSON-java-20190722_LICENSE.txt b/libs/licenses/JSON-java-20190722_LICENSE.txt
new file mode 100644 (file)
index 0000000..02ee0ef
--- /dev/null
@@ -0,0 +1,23 @@
+============================================================================
+
+Copyright (c) 2002 JSON.org
+
+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 shall be used for Good, not Evil.
+
+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.
diff --git a/libs/licenses/jexer-0.0.4_LICENSE.txt b/libs/licenses/jexer-0.0.4_LICENSE.txt
new file mode 100644 (file)
index 0000000..09bbfe0
--- /dev/null
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 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.
diff --git a/libs/licenses/nanohttpd-2.3.1-LICENSE.md b/libs/licenses/nanohttpd-2.3.1-LICENSE.md
new file mode 100644 (file)
index 0000000..8dc4ca7
--- /dev/null
@@ -0,0 +1,12 @@
+Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+* Neither the name of the NanoHttpd organization nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/libs/licenses/unbescape-1.1.4_LICENSE.txt b/libs/licenses/unbescape-1.1.4_LICENSE.txt
new file mode 100644 (file)
index 0000000..d645695
--- /dev/null
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz b/libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz
new file mode 100644 (file)
index 0000000..250f05f
Binary files /dev/null and b/libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz differ
diff --git a/libs/subtree.txt b/libs/subtree.txt
new file mode 100755 (executable)
index 0000000..aea12d8
--- /dev/null
@@ -0,0 +1,16 @@
+# Subtrees
+
+# The subtrees used for this program:
+#      ```git subtree add -P src/be/nikiroo/utils git@github.com:nikiroo/nikiroo-utils.git subtree```
+#      ```git subtree add -P src/jexer git@github.com:nikiroo/jexer.git subtree```
+
+# Update all subtrees:
+
+#git subtree pull -P src/be/nikiroo/utils git@github.com:nikiroo/nikiroo-utils.git subtree
+#git subtree pull -P src/jexer git@github.com:nikiroo/jexer.git subtree
+
+# Push all subtrees:
+
+git subtree push -P src/be/nikiroo/utils git@github.com:nikiroo/nikiroo-utils.git subtree
+git subtree push -P src/jexer git@github.com:nikiroo/jexer.git subtree
+
diff --git a/libs/unbescape-1.1.4-sources.jar b/libs/unbescape-1.1.4-sources.jar
new file mode 100644 (file)
index 0000000..01ddb56
Binary files /dev/null and b/libs/unbescape-1.1.4-sources.jar differ
diff --git a/libs/unbescape-1.1.4_ChangeLog.txt b/libs/unbescape-1.1.4_ChangeLog.txt
new file mode 100644 (file)
index 0000000..9cec6ec
--- /dev/null
@@ -0,0 +1,32 @@
+1.1.4.RELEASE
+=============
+- Added ampersand (&) to the list of characters to be escaped in LEVEL 1 for JSON, JavaScript and CSS literals
+  in order to make escaped code safe against code injection attacks in XHTML scenarios (browsers using XHTML
+  processing mode) performed by means of including XHTML escape codes in literals.
+
+1.1.3.RELEASE
+=============
+- Improved performance of String-based unescape methods for HTML, XML, JS, JSON and others when the
+  text to be unescaped actually needs no unescaping.
+
+1.1.2.RELEASE
+=============
+- Added support for stream-based (String-to-Writer and Reader-to-Writer) escape and unescape operations.
+
+1.1.1.RELEASE
+=============
+- Fixed HTML unescape for codepoints > U+10FFFF (was throwing IllegalArgumentException).
+- Fixed HTML unescape for codepoints > Integer.MAX_VALUE (was throwing ArrayIndexOutOfBounds).
+- Simplified and improved performance of codepoint-computing code by using Character.codePointAt(...) instead
+  of a complex conditional structure based on Character.isHighSurrogate(...) and Character.isLowSurrogate(...).
+- [doc] Fixed description of MSExcel-compatible CSV files.
+
+
+1.1.0.RELEASE
+=============
+- Added URI/URL escape and unescape operations.
+
+
+1.0
+===
+- First release of unbescape.
diff --git a/screenshots/README-fr.md b/screenshots/README-fr.md
new file mode 100644 (file)
index 0000000..7e77e61
--- /dev/null
@@ -0,0 +1,50 @@
+# Fanfix
+
+## Screenshots
+
+Fanfix peut utiliser plusieurs interfaces :
+
+- GUI: une interface basée sur Swing, pour afficher le programme sur votre PC graphiquement
+- TUI: une interface basée sur [jexer](https://gitlab.com/klamonte/jexer/), pour afficher des fenêtre et des boutons en mode texte
+- CLI: une interface purement en lignes de commandes, facile à automatiser dans un script ou pour utiliser dans un terminal texte
+
+Cette gallerie reprend des screenshots de plusieurs versions de Fanfix, mais les versions les plus récentes sont affichées en premier.
+
+### Version 2.0.0
+
+#### GUI
+
+![Fenêtre principale](fanfix-2.0.0-gui-library.png)
+
+![Propriétés d'une histoire](fanfix-2.0.0-gui-properties.png)
+
+![Menu](fanfix-2.0.0-gui-menu.png)
+
+#### TUI
+
+![Fenêtre principale](fanfix-2.0.0-tui-library.png)
+
+![Propriétés d'une histoire](fanfix-2.0.0-tui-properties.png)
+
+![Menu](fanfix-2.0.0-tui-menu.png)
+
+#### CLI
+
+![Fenêtre principale](fanfix-2.0.0-cli-library.png)
+
+![Propriétés d'une histoire](fanfix-2.0.0-cli-properties.png)
+
+![Menu](fanfix-2.0.0-cli-menu.png)
+
+### Version 1.3.2
+
+#### GUI
+
+![Fenêtre principale](fanfix-1.3.2.png)
+
+### Version 1.0.0
+
+#### GUI
+
+![Fenêtre principale](fanfix-1.0.0.png)
+
diff --git a/screenshots/README.md b/screenshots/README.md
new file mode 100644 (file)
index 0000000..d1500ae
--- /dev/null
@@ -0,0 +1,49 @@
+# Fanfix
+
+## Screenshots
+
+Fanfix can use different interfaces:
+
+- GUI: a Swing-based interface to display on your desktop
+- TUI: a [jexer](https://gitlab.com/klamonte/jexer/)-based interface to display in text mode, but still with window, buttons and other widgets
+- CLI: a fully automated CLI mode that can be used for scripts or to read on a terminal screen
+
+This screenshots gallery shows screenshots of different versions of Fanfix, but shows the more recent ones on top.
+
+### Version 2.0.0
+
+#### GUI
+
+![Main window](fanfix-2.0.0-gui-library.png)
+
+![Properties page](fanfix-2.0.0-gui-properties.png)
+
+![Menu](fanfix-2.0.0-gui-menu.png)
+
+#### TUI
+
+![Main window](fanfix-2.0.0-tui-library.png)
+
+![Properties page](fanfix-2.0.0-tui-properties.png)
+
+![Menu](fanfix-2.0.0-tui-menu.png)
+
+#### CLI
+
+![Main window](fanfix-2.0.0-cli-library.png)
+
+![Properties page](fanfix-2.0.0-cli-properties.png)
+
+![Menu](fanfix-2.0.0-cli-menu.png)
+
+### Version 1.3.2
+
+#### GUI
+
+![Main window](fanfix-1.3.2.png)
+
+### Version 1.0.0
+
+#### GUI
+
+![Main window](fanfix-1.0.0.png)
diff --git a/screenshots/fanfix-1.0.0.png b/screenshots/fanfix-1.0.0.png
new file mode 100644 (file)
index 0000000..d4d6c26
Binary files /dev/null and b/screenshots/fanfix-1.0.0.png differ
diff --git a/screenshots/fanfix-1.3.2.png b/screenshots/fanfix-1.3.2.png
new file mode 100644 (file)
index 0000000..6ea39ce
Binary files /dev/null and b/screenshots/fanfix-1.3.2.png differ
diff --git a/src/be/nikiroo/fanfix/DataLoader.java b/src/be/nikiroo/fanfix/DataLoader.java
new file mode 100644 (file)
index 0000000..901e8da
--- /dev/null
@@ -0,0 +1,396 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Map;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.utils.Cache;
+import be.nikiroo.utils.CacheMemory;
+import be.nikiroo.utils.Downloader;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.TraceHandler;
+
+/**
+ * This cache will manage Internet (and local) downloads, as well as put the
+ * downloaded files into a cache.
+ * <p>
+ * As long the cached resource is not too old, it will use it instead of
+ * retrieving the file again.
+ * 
+ * @author niki
+ */
+public class DataLoader {
+       private Downloader downloader;
+       private Downloader downloaderNoCache;
+       private Cache cache;
+       private boolean offline;
+
+       /**
+        * Create a new {@link DataLoader} object.
+        * 
+        * @param dir
+        *            the directory to use as cache
+        * @param UA
+        *            the User-Agent to use to download the resources
+        * @param hoursChanging
+        *            the number of hours after which a cached file that is thought
+        *            to change ~often is considered too old (or -1 for
+        *            "never too old")
+        * @param hoursStable
+        *            the number of hours after which a LARGE cached file that is
+        *            thought to change rarely is considered too old (or -1 for
+        *            "never too old")
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public DataLoader(File dir, String UA, int hoursChanging, int hoursStable)
+                       throws IOException {
+               downloader = new Downloader(UA, new Cache(dir, hoursChanging,
+                               hoursStable));
+               downloaderNoCache = new Downloader(UA);
+
+               cache = downloader.getCache();
+       }
+
+       /**
+        * Create a new {@link DataLoader} object without disk cache (will keep a
+        * memory cache for manual cache operations).
+        * 
+        * @param UA
+        *            the User-Agent to use to download the resources
+        */
+       public DataLoader(String UA) {
+               downloader = new Downloader(UA);
+               downloaderNoCache = downloader;
+               cache = new CacheMemory();
+       }
+       
+       /**
+        * This {@link Downloader} is forbidden to try and connect to the network.
+        * <p>
+        * If TRUE, it will only check the cache (even in no-cache mode!).
+        * <p>
+        * Default is FALSE.
+        * 
+        * @return TRUE if offline
+        */
+       public boolean isOffline() {
+               return offline;
+       }
+       
+       /**
+        * This {@link Downloader} is forbidden to try and connect to the network.
+        * <p>
+        * If TRUE, it will only check the cache (even in no-cache mode!).
+        * <p>
+        * Default is FALSE.
+        * 
+        * @param offline TRUE for offline, FALSE for online
+        */
+       public void setOffline(boolean offline) {
+               this.offline = offline;
+               downloader.setOffline(offline);
+               downloaderNoCache.setOffline(offline);
+               
+               // If we don't, we cannot support no-cache using code in OFFLINE mode
+               if (offline) {
+                       downloaderNoCache.setCache(cache);
+               } else {
+                       downloaderNoCache.setCache(null);
+               }
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               downloader.setTraceHandler(tracer);
+               downloaderNoCache.setTraceHandler(tracer);
+               cache.setTraceHandler(tracer);
+               if (downloader.getCache() != null) {
+                       downloader.getCache().setTraceHandler(tracer);
+               }
+
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the opened resource, NOT NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, BasicSupport support, boolean stable)
+                       throws IOException {
+               return open(url, url, support, stable, null, null, null);
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param originalUrl
+        *            the original {@link URL} before any redirection occurs, which
+        *            is also used for the cache ID if needed (so we can retrieve
+        *            the content with this URL if needed)
+        * @param support
+        *            the support to use to download the resource
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the opened resource, NOT NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, URL originalUrl, BasicSupport support,
+                       boolean stable) throws IOException {
+               return open(url, originalUrl, support, stable, null, null, null);
+       }
+
+       /**
+        * Open a resource (will load it from the cache if possible, or save it into
+        * the cache after downloading if not).
+        * <p>
+        * The cached resource will be assimilated to the given original {@link URL}
+        * 
+        * @param url
+        *            the resource to open
+        * @param originalUrl
+        *            the original {@link URL} before any redirection occurs, which
+        *            is also used for the cache ID if needed (so we can retrieve
+        *            the content with this URL if needed)
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorization (aka, "bearer XXXXXXX")
+        * 
+        * @return the opened resource, NOT NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream open(URL url, URL originalUrl, BasicSupport support,
+                       boolean stable, Map<String, String> postParams,
+                       Map<String, String> getParams, String oauth) throws IOException {
+
+               Map<String, String> cookiesValues = null;
+               URL currentReferer = url;
+
+               if (support != null) {
+                       cookiesValues = support.getCookies();
+                       currentReferer = support.getCurrentReferer();
+                       // priority: arguments
+                       if (oauth == null) {
+                               oauth = support.getOAuth();
+                       }
+               }
+
+               return downloader.open(url, originalUrl, currentReferer, cookiesValues,
+                               postParams, getParams, oauth, stable);
+       }
+
+       /**
+        * Open the given {@link URL} without using the cache, but still using and
+        * updating the cookies.
+        * 
+        * @param url
+        *            the {@link URL} to open
+        * @param support
+        *            the {@link BasicSupport} used for the cookies
+        * @param postParams
+        *            the POST parameters
+        * @param getParams
+        *            the GET parameters (priority over POST)
+        * @param oauth
+        *            OAuth authorization (aka, "bearer XXXXXXX")
+        * 
+        * @return the {@link InputStream} of the opened page
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public InputStream openNoCache(URL url, BasicSupport support,
+                       Map<String, String> postParams, Map<String, String> getParams,
+                       String oauth) throws IOException {
+
+               Map<String, String> cookiesValues = null;
+               URL currentReferer = url;
+               if (support != null) {
+                       cookiesValues = support.getCookies();
+                       currentReferer = support.getCurrentReferer();
+                       // priority: arguments
+                       if (oauth == null) {
+                               oauth = support.getOAuth();
+                       }
+               }
+
+               return downloaderNoCache.open(url, currentReferer, cookiesValues,
+                               postParams, getParams, oauth);
+       }
+
+       /**
+        * Refresh the resource into cache if needed.
+        * 
+        * @param url
+        *            the resource to open
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void refresh(URL url, BasicSupport support, boolean stable)
+                       throws IOException {
+               if (!check(url, stable)) {
+                       open(url, url, support, stable, null, null, null).close();
+               }
+       }
+
+       /**
+        * Check the resource to see if it is in the cache.
+        * 
+        * @param url
+        *            the resource to check
+        * @param stable
+        *            a stable file (that dones't change too often) -- parameter
+        *            used to check if the file is too old to keep or not
+        * 
+        * @return TRUE if it is
+        * 
+        */
+       public boolean check(URL url, boolean stable) {
+               return downloader.getCache() != null
+                               && downloader.getCache().check(url, false, stable);
+       }
+
+       /**
+        * Save the given resource as an image on disk using the default image
+        * format for content or cover -- will automatically add the extension, too.
+        * 
+        * @param img
+        *            the resource
+        * @param target
+        *            the target file without extension
+        * @param cover
+        *            use the cover image format instead of the content image format
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void saveAsImage(Image img, File target, boolean cover)
+                       throws IOException {
+               String format;
+               if (cover) {
+                       format = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+               } else {
+                       format = Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_CONTENT)
+                                       .toLowerCase();
+               }
+               saveAsImage(img, new File(target.toString() + "." + format), format);
+       }
+
+       /**
+        * Save the given resource as an image on disk using the given image format
+        * for content, or with "png" format if it fails.
+        * 
+        * @param img
+        *            the resource
+        * @param target
+        *            the target file
+        * @param format
+        *            the file format ("png", "jpeg", "bmp"...)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void saveAsImage(Image img, File target, String format)
+                       throws IOException {
+               ImageUtils.getInstance().saveAsImage(img, target, format);
+       }
+
+       /**
+        * Manually add this item to the cache.
+        * 
+        * @param in
+        *            the input data
+        * @param uniqueID
+        *            a unique ID for this resource
+        * 
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void addToCache(InputStream in, String uniqueID) throws IOException {
+               cache.save(in, uniqueID);
+       }
+
+       /**
+        * Return the {@link InputStream} corresponding to the given unique ID, or
+        * NULL if none found.
+        * 
+        * @param uniqueID
+        *            the unique ID
+        * 
+        * @return the content or NULL
+        */
+       public InputStream getFromCache(String uniqueID) {
+               return cache.load(uniqueID, true, true);
+       }
+
+       /**
+        * Remove the given resource from the cache.
+        * 
+        * @param uniqueID
+        *            a unique ID used to locate the cached resource
+        * 
+        * @return TRUE if it was removed
+        */
+       public boolean removeFromCache(String uniqueID) {
+               return cache.remove(uniqueID);
+       }
+
+       /**
+        * Clean the cache (delete the cached items).
+        * 
+        * @param onlyOld
+        *            only clean the files that are considered too old
+        * 
+        * @return the number of cleaned items
+        */
+       public int cleanCache(boolean onlyOld) {
+               return cache.clean(onlyOld);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/Instance.java b/src/be/nikiroo/fanfix/Instance.java
new file mode 100644 (file)
index 0000000..a2cb90a
--- /dev/null
@@ -0,0 +1,699 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.bundles.StringIdBundle;
+import be.nikiroo.fanfix.bundles.StringIdGuiBundle;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.bundles.UiConfigBundle;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.fanfix.library.WebLibrary;
+import be.nikiroo.utils.Cache;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Proxy;
+import be.nikiroo.utils.TempFiles;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.resources.Bundles;
+
+/**
+ * Global state for the program (services and singletons).
+ * 
+ * @author niki
+ */
+public class Instance {
+       static private Instance instance;
+       static private Object instancelock = new Object();
+
+       private ConfigBundle config;
+       private UiConfigBundle uiconfig;
+       private StringIdBundle trans;
+       private DataLoader cache;
+       private StringIdGuiBundle transGui;
+       private BasicLibrary lib;
+       private File coverDir;
+       private File readerTmp;
+       private File remoteDir;
+       private String configDir;
+       private TraceHandler tracer;
+       private TempFiles tempFiles;
+
+       /**
+        * Initialise the instance -- if already initialised, nothing will happen.
+        * <p>
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
+        * <p>
+        * Note that this method will honour some environment variables, the 3 most
+        * important ones probably being:
+        * <ul>
+        * <li><tt>DEBUG</tt>: will enable DEBUG output if set to 1 (or Y or TRUE or
+        * ON, case insensitive)</li>
+        * <li><tt>CONFIG_DIR</tt>: will use this directory as configuration
+        * directory (supports $HOME notation, defaults to $HOME/.fanfix</li>
+        * <li><tt>BOOKS_DIR</tt>: will use this directory as library directory
+        * (supports $HOME notation, defaults to $HOME/Books</li>
+        * </ul>
+        */
+       static public void init() {
+               init(false);
+       }
+
+       /**
+        * Initialise the instance -- if already initialised, nothing will happen
+        * unless you pass TRUE to <tt>force</tt>.
+        * <p>
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
+        * <p>
+        * Note: forcing the initialisation can be dangerous, so make sure to only
+        * make it under controlled circumstances -- for instance, at the start of
+        * the program, you could call {@link Instance#init()}, change some settings
+        * because you want to force those settings (it will also forbid users to
+        * change them!) and then call {@link Instance#init(boolean)} with
+        * <tt>force</tt> set to TRUE.
+        * 
+        * @param force
+        *            force the initialisation even if already initialised
+        */
+       static public void init(boolean force) {
+               synchronized (instancelock) {
+                       if (instance == null || force) {
+                               instance = new Instance();
+                       }
+               }
+
+       }
+
+       /**
+        * Force-initialise the {@link Instance} to a known value.
+        * <p>
+        * Usually for DEBUG/Test purposes.
+        * 
+        * @param instance
+        *            the actual Instance to use
+        */
+       static public void init(Instance instance) {
+               Instance.instance = instance;
+       }
+
+       /**
+        * The (mostly unique) instance of this {@link Instance}.
+        * 
+        * @return the (mostly unique) instance
+        */
+       public static Instance getInstance() {
+               return instance;
+       }
+
+       /**
+        * Actually initialise the instance.
+        * <p>
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
+        */
+       protected Instance() {
+               // Before we can configure it:
+               Boolean debug = checkEnv("DEBUG");
+               boolean trace = debug != null && debug;
+               tracer = new TraceHandler(true, trace, trace);
+
+               // config dir:
+               configDir = getConfigDir();
+               if (!new File(configDir).exists()) {
+                       new File(configDir).mkdirs();
+               }
+
+               // Most of the rest is dependent upon this:
+               createConfigs(configDir, false);
+
+               // Proxy support
+               Proxy.use(config.getString(Config.NETWORK_PROXY));
+
+               // update tracer:
+               if (debug == null) {
+                       debug = config.getBoolean(Config.DEBUG_ERR, false);
+                       trace = config.getBoolean(Config.DEBUG_TRACE, false);
+               }
+
+               tracer = new TraceHandler(true, debug, trace);
+
+               // default Library
+               remoteDir = new File(configDir, "remote");
+               lib = createDefaultLibrary(remoteDir);
+
+               // create cache and TMP
+               File tmp = getFile(Config.CACHE_DIR, configDir, "tmp");
+               Image.setTemporaryFilesRoot(new File(tmp.getParent(), "tmp.images"));
+
+               String ua = config.getString(Config.NETWORK_USER_AGENT, "");
+               try {
+                       int hours = config.getInteger(Config.CACHE_MAX_TIME_CHANGING, 0);
+                       int hoursLarge = config.getInteger(Config.CACHE_MAX_TIME_STABLE, 0);
+                       cache = new DataLoader(tmp, ua, hours, hoursLarge);
+               } catch (IOException e) {
+                       tracer.error(new IOException(
+                                       "Cannot create cache (will continue without cache)", e));
+                       cache = new DataLoader(ua);
+               }
+
+               cache.setTraceHandler(tracer);
+
+               // readerTmp / coverDir
+               readerTmp = getFile(UiConfig.CACHE_DIR_LOCAL_READER, configDir,
+                               "tmp-reader");
+               coverDir = getFile(Config.DEFAULT_COVERS_DIR, configDir, "covers");
+               coverDir.mkdirs();
+
+               try {
+                       tempFiles = new TempFiles("fanfix");
+               } catch (IOException e) {
+                       tracer.error(
+                                       new IOException("Cannot create temporary directory", e));
+               }
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * <p>
+        * It is never NULL.
+        * 
+        * @return the traces handler (never NULL)
+        */
+       public TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link Cache}.
+        * 
+        * @param tracer
+        *            the new traces handler or NULL
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               this.tracer = tracer;
+               cache.setTraceHandler(tracer);
+       }
+
+       /**
+        * Get the (unique) configuration service for the program.
+        * 
+        * @return the configuration service
+        */
+       public ConfigBundle getConfig() {
+               return config;
+       }
+
+       /**
+        * Get the (unique) UI configuration service for the program.
+        * 
+        * @return the configuration service
+        */
+       public UiConfigBundle getUiConfig() {
+               return uiconfig;
+       }
+
+       /**
+        * Reset the configuration.
+        * 
+        * @param resetTrans
+        *            also reset the translation files
+        */
+       public void resetConfig(boolean resetTrans) {
+               String dir = Bundles.getDirectory();
+               Bundles.setDirectory(null);
+               try {
+                       try {
+                               ConfigBundle config = new ConfigBundle();
+                               config.updateFile(configDir);
+                       } catch (IOException e) {
+                               tracer.error(e);
+                       }
+                       try {
+                               UiConfigBundle uiconfig = new UiConfigBundle();
+                               uiconfig.updateFile(configDir);
+                       } catch (IOException e) {
+                               tracer.error(e);
+                       }
+
+                       if (resetTrans) {
+                               try {
+                                       StringIdBundle trans = new StringIdBundle(null);
+                                       trans.updateFile(configDir);
+                               } catch (IOException e) {
+                                       tracer.error(e);
+                               }
+                       }
+               } finally {
+                       Bundles.setDirectory(dir);
+               }
+       }
+
+       /**
+        * Get the (unique) {@link DataLoader} for the program.
+        * 
+        * @return the {@link DataLoader}
+        */
+       public DataLoader getCache() {
+               return cache;
+       }
+
+       /**
+        * Get the (unique) {link StringIdBundle} for the program.
+        * <p>
+        * This is used for the translations of the core parts of Fanfix.
+        * 
+        * @return the {link StringIdBundle}
+        */
+       public StringIdBundle getTrans() {
+               return trans;
+       }
+
+       /**
+        * Get the (unique) {link StringIdGuiBundle} for the program.
+        * <p>
+        * This is used for the translations of the GUI parts of Fanfix.
+        * 
+        * @return the {link StringIdGuiBundle}
+        */
+       public StringIdGuiBundle getTransGui() {
+               return transGui;
+       }
+
+       /**
+        * Get the (unique) {@link BasicLibrary} for the program.
+        * 
+        * @return the {@link BasicLibrary}
+        */
+       public BasicLibrary getLibrary() {
+               if (lib == null) {
+                       throw new NullPointerException("We don't have a library to return");
+               }
+
+               return lib;
+       }
+
+       /**
+        * Change the default {@link BasicLibrary} for this program.
+        * <p>
+        * Be careful.
+        * 
+        * @param lib
+        *            the new {@link BasicLibrary}
+        */
+       public void setLibrary(BasicLibrary lib) {
+               this.lib = lib;
+       }
+
+       /**
+        * Return the directory where to look for default cover pages.
+        * 
+        * @return the default covers directory
+        */
+       public File getCoverDir() {
+               return coverDir;
+       }
+
+       /**
+        * Return the directory where to store temporary files for the local reader.
+        * 
+        * @return the directory
+        */
+       public File getReaderDir() {
+               return readerTmp;
+       }
+
+       /**
+        * Return the directory where to store temporary files for the remote
+        * {@link LocalLibrary}.
+        * 
+        * @param host
+        *            the remote for this host
+        * 
+        * @return the directory
+        */
+       public File getRemoteDir(String host) {
+               return getRemoteDir(remoteDir, host);
+       }
+
+       /**
+        * Return the directory where to store temporary files for the remote
+        * {@link LocalLibrary}.
+        * 
+        * @param remoteDir
+        *            the base remote directory
+        * @param host
+        *            the remote for this host
+        * 
+        * @return the directory
+        */
+       private File getRemoteDir(File remoteDir, String host) {
+               remoteDir.mkdirs();
+
+               if (host != null) {
+                       return new File(remoteDir, host);
+               }
+
+               return remoteDir;
+       }
+
+       /**
+        * Check if we need to check that a new version of Fanfix is available.
+        * 
+        * @return TRUE if we need to
+        */
+       public boolean isVersionCheckNeeded() {
+               try {
+                       long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0)
+                                       * 24 * 60 * 60 * 1000;
+                       if (wait >= 0) {
+                               String lastUpString = IOUtils
+                                               .readSmallFile(new File(configDir, "LAST_UPDATE"));
+                               long delay = new Date().getTime()
+                                               - Long.parseLong(lastUpString);
+                               if (delay > wait) {
+                                       return true;
+                               }
+                       } else {
+                               return false;
+                       }
+               } catch (Exception e) {
+                       // No file or bad file:
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Notify that we checked for a new version of Fanfix.
+        */
+       public void setVersionChecked() {
+               try {
+                       IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE",
+                                       Long.toString(new Date().getTime()));
+               } catch (IOException e) {
+                       tracer.error(e);
+               }
+       }
+
+       /**
+        * The facility to use temporary files in this program.
+        * <p>
+        * <b>MUST</b> be closed at end of program.
+        * 
+        * @return the facility
+        */
+       public TempFiles getTempFiles() {
+               return tempFiles;
+       }
+
+       /**
+        * The configuration directory (will check, in order of preference, the
+        * system properties, the environment and then defaults to
+        * {@link Instance#getHome()}/.fanfix).
+        * 
+        * @return the config directory
+        */
+       private String getConfigDir() {
+               String configDir = System.getProperty("CONFIG_DIR");
+
+               if (configDir == null) {
+                       configDir = System.getenv("CONFIG_DIR");
+               }
+
+               if (configDir == null) {
+                       configDir = new File(getHome(), ".fanfix").getPath();
+               }
+
+               return configDir;
+       }
+
+       /**
+        * Create the config variables ({@link Instance#config},
+        * {@link Instance#uiconfig}, {@link Instance#trans} and
+        * {@link Instance#transGui}).
+        * 
+        * @param configDir
+        *            the directory where to find the configuration files
+        * @param refresh
+        *            TRUE to reset the configuration files from the default
+        *            included ones
+        */
+       private void createConfigs(String configDir, boolean refresh) {
+               if (!refresh) {
+                       Bundles.setDirectory(configDir);
+               }
+
+               try {
+                       config = new ConfigBundle();
+                       config.updateFile(configDir);
+               } catch (IOException e) {
+                       tracer.error(e);
+               }
+
+               try {
+                       uiconfig = new UiConfigBundle();
+                       uiconfig.updateFile(configDir);
+               } catch (IOException e) {
+                       tracer.error(e);
+               }
+
+               // No updateFile for this one! (we do not want the user to have custom
+               // translations that won't accept updates from newer versions)
+               trans = new StringIdBundle(getLang());
+               transGui = new StringIdGuiBundle(getLang());
+
+               // Fix an old bug (we used to store custom translation files by
+               // default):
+               if (trans.getString(StringId.INPUT_DESC_CBZ) == null) {
+                       trans.deleteFile(configDir);
+               }
+
+               Boolean noutf = checkEnv("NOUTF");
+               if (noutf != null && noutf) {
+                       trans.setUnicode(false);
+                       transGui.setUnicode(false);
+               }
+
+               Bundles.setDirectory(configDir);
+       }
+
+       /**
+        * Create the default library as specified by the config.
+        * 
+        * @param remoteDir
+        *            the base remote directory if needed
+        * 
+        * @return the default {@link BasicLibrary}
+        */
+       private BasicLibrary createDefaultLibrary(File remoteDir) {
+               BasicLibrary lib = null;
+
+               boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED,
+                               false);
+               if (useRemote) {
+                       String host = null;
+                       int port = -1;
+                       try {
+                               host = config.getString(Config.REMOTE_LIBRARY_HOST,
+                                               "fanfix://localhost");
+                               port = config.getInteger(Config.REMOTE_LIBRARY_PORT, -1);
+                               String key = config.getString(Config.REMOTE_LIBRARY_KEY);
+
+                               if (!host.startsWith("http://") && !host.startsWith("https://")
+                                               && !host.startsWith("fanfix://")) {
+                                       host = "fanfix://" + host;
+                               }
+
+                               tracer.trace("Selecting remote library " + host + ":" + port);
+
+                               if (host.startsWith("fanfix://")) {
+                                       lib = new RemoteLibrary(key, host, port);
+                               } else {
+                                       lib = new WebLibrary(key, host, port);
+                               }
+
+                               lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib,
+                                               uiconfig);
+                       } catch (Exception e) {
+                               tracer.error(
+                                               new IOException("Cannot create remote library for: "
+                                                               + host + ":" + port, e));
+                       }
+               } else {
+                       String libDir = System.getenv("BOOKS_DIR");
+                       if (libDir == null || libDir.isEmpty()) {
+                               libDir = getFile(Config.LIBRARY_DIR, configDir, "$HOME/Books")
+                                               .getPath();
+                       }
+                       try {
+                               lib = new LocalLibrary(new File(libDir), config);
+                       } catch (Exception e) {
+                               tracer.error(new IOException(
+                                               "Cannot create library for directory: " + libDir, e));
+                       }
+               }
+
+               return lib;
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @param id
+        *            the key for the path, which may contain "$HOME"
+        * @param configDir
+        *            the directory to use as base if not absolute
+        * @param def
+        *            the default value if none (will be configDir-rooted if needed)
+        * @return the path, with expanded "$HOME" if needed
+        */
+       protected File getFile(Config id, String configDir, String def) {
+               String path = config.getString(id, def);
+               return getFile(path, configDir);
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @param id
+        *            the key for the path, which may contain "$HOME"
+        * @param configDir
+        *            the directory to use as base if not absolute
+        * @param def
+        *            the default value if none (will be configDir-rooted if needed)
+        * @return the path, with expanded "$HOME" if needed
+        */
+       protected File getFile(UiConfig id, String configDir, String def) {
+               String path = uiconfig.getString(id, def);
+               return getFile(path, configDir);
+       }
+
+       /**
+        * Return a path, but support the special $HOME variable.
+        * 
+        * @param path
+        *            the path, which may contain "$HOME"
+        * @param configDir
+        *            the directory to use as base if not absolute
+        * @return the path, with expanded "$HOME" if needed
+        */
+       protected File getFile(String path, String configDir) {
+               File file = null;
+               if (path != null && !path.isEmpty()) {
+                       path = path.replace('/', File.separatorChar);
+                       if (path.contains("$HOME")) {
+                               path = path.replace("$HOME", getHome());
+                       } else if (!path.startsWith("/")) {
+                               path = new File(configDir, path).getPath();
+                       }
+
+                       file = new File(path);
+               }
+
+               return file;
+       }
+
+       /**
+        * Return the home directory from the environment (FANFIX_DIR) or the system
+        * properties.
+        * <p>
+        * The environment variable is tested first. Then, the custom property
+        * "fanfix.home" is tried, followed by the usual "user.home" then
+        * "java.io.tmp" if nothing else is found.
+        * 
+        * @return the home
+        */
+       protected String getHome() {
+               String home = System.getenv("FANFIX_DIR");
+               if (home != null && new File(home).isFile()) {
+                       home = null;
+               }
+
+               if (home == null || home.trim().isEmpty()) {
+                       home = System.getProperty("fanfix.home");
+                       if (home != null && new File(home).isFile()) {
+                               home = null;
+                       }
+               }
+
+               if (home == null || home.trim().isEmpty()) {
+                       home = System.getProperty("user.home");
+                       if (!new File(home).isDirectory()) {
+                               home = null;
+                       }
+               }
+
+               if (home == null || home.trim().isEmpty()) {
+                       home = System.getProperty("java.io.tmpdir");
+                       if (!new File(home).isDirectory()) {
+                               home = null;
+                       }
+               }
+
+               if (home == null) {
+                       home = "";
+               }
+
+               return home;
+       }
+
+       /**
+        * The language to use for the application (NULL = default system language).
+        * 
+        * @return the language
+        */
+       protected String getLang() {
+               String lang = config.getString(Config.LANG);
+
+               if (lang == null || lang.isEmpty()) {
+                       if (System.getenv("LANG") != null
+                                       && !System.getenv("LANG").isEmpty()) {
+                               lang = System.getenv("LANG");
+                       }
+               }
+
+               if (lang != null && lang.isEmpty()) {
+                       lang = null;
+               }
+
+               return lang;
+       }
+
+       /**
+        * Check that the given environment variable is "enabled".
+        * 
+        * @param key
+        *            the variable to check
+        * 
+        * @return TRUE if it is
+        */
+       protected Boolean checkEnv(String key) {
+               String value = System.getenv(key);
+               if (value != null) {
+                       value = value.trim().toLowerCase();
+                       if ("yes".equals(value) || "true".equals(value)
+                                       || "on".equals(value) || "1".equals(value)
+                                       || "y".equals(value)) {
+                               return true;
+                       }
+
+                       return false;
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/Main.java b/src/be/nikiroo/fanfix/Main.java
new file mode 100644 (file)
index 0000000..7be305a
--- /dev/null
@@ -0,0 +1,1104 @@
+package be.nikiroo.fanfix;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.CacheLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.fanfix.library.RemoteLibraryServer;
+import be.nikiroo.fanfix.library.WebLibraryServer;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.reader.BasicReader;
+import be.nikiroo.fanfix.reader.CliReader;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.VersionCheck;
+
+/**
+ * Main program entry point.
+ * 
+ * @author niki
+ */
+public class Main {
+       private enum MainAction {
+               IMPORT, EXPORT, CONVERT, READ, READ_URL, LIST, HELP, START, VERSION, SERVER, STOP_SERVER, REMOTE, SET_SOURCE, SET_TITLE, SET_AUTHOR, SEARCH, SEARCH_TAG
+       }
+
+       /**
+        * Main program entry point.
+        * <p>
+        * Known environment variables:
+        * <ul>
+        * <li>NOUTF: if set to 1 or 'true', the program will prefer non-unicode
+        * {@link String}s when possible</li>
+        * <li>CONFIG_DIR: a path where to look for the <tt>.properties</tt> files
+        * before taking the usual ones; they will also be saved/updated into this
+        * path when the program starts</li>
+        * <li>DEBUG: if set to 1 or 'true', the program will override the DEBUG_ERR
+        * configuration value with 'true'</li>
+        * </ul>
+        * <p>
+        * <ul>
+        * <li>--import [URL]: import into library</li>
+        * <li>--export [id] [output_type] [target]: export story to target</li>
+        * <li>--convert [URL] [output_type] [target] (+info): convert URL into
+        * target</li>
+        * <li>--read [id] ([chapter number]): read the given story from the library
+        * </li>
+        * <li>--read-url [URL] ([chapter number]): convert on the fly and read the
+        * story, without saving it</li>
+        * <li>--search: list the supported websites (where)</li>
+        * <li>--search [where] [keywords] (page [page]) (item [item]): search on
+        * the supported website and display the given results page of stories it
+        * found, or the story details if asked</li>
+        * <li>--search-tag [where]: list all the tags supported by this website</li>
+        * <li>--search-tag [index 1]... (page [page]) (item [item]): search for the
+        * given stories or subtags, tag by tag, and display information about a
+        * specific page of results or about a specific item if requested</li>
+        * <li>--list ([type]): list the stories present in the library</li>
+        * <li>--set-source [id] [new source]: change the source of the given story</li>
+        * <li>--set-title [id] [new title]: change the title of the given story</li>
+        * <li>--set-author [id] [new author]: change the author of the given story</li>
+        * <li>--version: get the version of the program</li>
+        * <li>--server: start the server mode (see config file for parameters)</li>
+        * <li>--stop-server: stop the running server on this port if any</li>
+        * <li>--remote [key] [host] [port]: use the given remote library</li>
+        * </ul>
+        * 
+        * @param args
+        *            see method description
+        */
+       public static void main(String[] args) {
+               new Main().start(args);
+       }
+
+       /**
+        * Start the default handling for the application.
+        * <p>
+        * If specific actions were asked (with correct parameters), they will be
+        * forwarded to the different protected methods that you can override.
+        * <p>
+        * At the end of the method, {@link Main#exit(int)} will be called; by
+        * default, it calls {@link System#exit(int)} if the status is not 0.
+        * 
+        * @param args
+        *            the arguments received from the system
+        */
+       public void start(String [] args) {
+               // Only one line, but very important:
+               Instance.init();
+
+               String urlString = null;
+               String luid = null;
+               String sourceString = null;
+               String titleString = null;
+               String authorString = null;
+               String chapString = null;
+               String target = null;
+               String key = null;
+               MainAction action = MainAction.START;
+               Boolean plusInfo = null;
+               String host = null;
+               Integer port = null;
+               SupportType searchOn = null;
+               String search = null;
+               List<Integer> tags = new ArrayList<Integer>();
+               Integer page = null;
+               Integer item = null;
+
+               boolean noMoreActions = false;
+
+               int exitCode = 0;
+               for (int i = 0; exitCode == 0 && i < args.length; i++) {
+                       if (args[i] == null)
+                               continue;
+
+                       // Action (--) handling:
+                       if (!noMoreActions && args[i].startsWith("--")) {
+                               if (args[i].equals("--")) {
+                                       noMoreActions = true;
+                               } else {
+                                       try {
+                                               action = MainAction.valueOf(args[i].substring(2)
+                                                               .toUpperCase().replace("-", "_"));
+                                       } catch (Exception e) {
+                                               Instance.getInstance().getTraceHandler()
+                                                               .error(new IllegalArgumentException("Unknown action: " + args[i], e));
+                                               exitCode = 255;
+                                       }
+                               }
+
+                               continue;
+                       }
+
+                       switch (action) {
+                       case IMPORT:
+                               if (urlString == null) {
+                                       urlString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case EXPORT:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else if (target == null) {
+                                       target = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case CONVERT:
+                               if (urlString == null) {
+                                       urlString = args[i];
+                               } else if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else if (target == null) {
+                                       target = args[i];
+                               } else if (plusInfo == null) {
+                                       if ("+info".equals(args[i])) {
+                                               plusInfo = true;
+                                       } else {
+                                               exitCode = 255;
+                                       }
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case LIST:
+                               if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SET_SOURCE:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       sourceString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SET_TITLE:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       titleString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SET_AUTHOR:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (sourceString == null) {
+                                       authorString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case READ:
+                               if (luid == null) {
+                                       luid = args[i];
+                               } else if (chapString == null) {
+                                       chapString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case READ_URL:
+                               if (urlString == null) {
+                                       urlString = args[i];
+                               } else if (chapString == null) {
+                                       chapString = args[i];
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SEARCH:
+                               if (searchOn == null) {
+                                       searchOn = SupportType.valueOfAllOkUC(args[i]);
+
+                                       if (searchOn == null) {
+                                               Instance.getInstance().getTraceHandler().error("Website not known: <" + args[i] + ">");
+                                               exitCode = 41;
+                                               break;
+                                       }
+
+                                       if (BasicSearchable.getSearchable(searchOn) == null) {
+                                               Instance.getInstance().getTraceHandler().error("Website not supported: " + searchOn);
+                                               exitCode = 42;
+                                               break;
+                                       }
+                               } else if (search == null) {
+                                       search = args[i];
+                               } else if (page != null && page == -1) {
+                                       try {
+                                               page = Integer.parseInt(args[i]);
+                                       } catch (Exception e) {
+                                               page = -2;
+                                       }
+                               } else if (item != null && item == -1) {
+                                       try {
+                                               item = Integer.parseInt(args[i]);
+                                       } catch (Exception e) {
+                                               item = -2;
+                                       }
+                               } else if (page == null || item == null) {
+                                       if (page == null && "page".equals(args[i])) {
+                                               page = -1;
+                                       } else if (item == null && "item".equals(args[i])) {
+                                               item = -1;
+                                       } else {
+                                               exitCode = 255;
+                                       }
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case SEARCH_TAG:
+                               if (searchOn == null) {
+                                       searchOn = SupportType.valueOfAllOkUC(args[i]);
+
+                                       if (searchOn == null) {
+                                               Instance.getInstance().getTraceHandler().error("Website not known: <" + args[i] + ">");
+                                               exitCode = 255;
+                                       }
+
+                                       if (BasicSearchable.getSearchable(searchOn) == null) {
+                                               Instance.getInstance().getTraceHandler().error("Website not supported: " + searchOn);
+                                               exitCode = 255;
+                                       }
+                               } else if (page == null && item == null) {
+                                       if ("page".equals(args[i])) {
+                                               page = -1;
+                                       } else if ("item".equals(args[i])) {
+                                               item = -1;
+                                       } else {
+                                               try {
+                                                       int index = Integer.parseInt(args[i]);
+                                                       tags.add(index);
+                                               } catch (NumberFormatException e) {
+                                                       Instance.getInstance().getTraceHandler().error("Invalid tag index: " + args[i]);
+                                                       exitCode = 255;
+                                               }
+                                       }
+                               } else if (page != null && page == -1) {
+                                       try {
+                                               page = Integer.parseInt(args[i]);
+                                       } catch (Exception e) {
+                                               page = -2;
+                                       }
+                               } else if (item != null && item == -1) {
+                                       try {
+                                               item = Integer.parseInt(args[i]);
+                                       } catch (Exception e) {
+                                               item = -2;
+                                       }
+                               } else if (page == null || item == null) {
+                                       if (page == null && "page".equals(args[i])) {
+                                               page = -1;
+                                       } else if (item == null && "item".equals(args[i])) {
+                                               item = -1;
+                                       } else {
+                                               exitCode = 255;
+                                       }
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       case HELP:
+                               exitCode = 255;
+                               break;
+                       case START:
+                               exitCode = 255; // not supposed to be selected by user
+                               break;
+                       case VERSION:
+                               exitCode = 255; // no arguments for this option
+                               break;
+                       case SERVER:
+                               exitCode = 255; // no arguments for this option
+                               break;
+                       case STOP_SERVER:
+                               exitCode = 255; // no arguments for this option
+                               break;
+                       case REMOTE:
+                               if (key == null) {
+                                       key = args[i];
+                               } else if (host == null) {
+                                       host = args[i];
+                               } else if (port == null) {
+                                       port = Integer.parseInt(args[i]);
+
+                                       BasicLibrary lib = new RemoteLibrary(key, host, port);
+                                       lib = new CacheLibrary(
+                                                       Instance.getInstance().getRemoteDir(host), lib,
+                                                       Instance.getInstance().getUiConfig());
+
+                                       Instance.getInstance().setLibrary(lib);
+
+                                       action = MainAction.START;
+                               } else {
+                                       exitCode = 255;
+                               }
+                               break;
+                       }
+               }
+
+               final Progress mainProgress = new Progress(0, 80);
+               mainProgress.addProgressListener(new Progress.ProgressListener() {
+                       private int current = mainProgress.getMin();
+
+                       @Override
+                       public void progress(Progress progress, String name) {
+                               int diff = progress.getProgress() - current;
+                               current += diff;
+
+                               if (diff <= 0)
+                                       return;
+
+                               StringBuilder builder = new StringBuilder();
+                               for (int i = 0; i < diff; i++) {
+                                       builder.append('.');
+                               }
+
+                               System.err.print(builder.toString());
+
+                               if (progress.isDone()) {
+                                       System.err.println("");
+                               }
+                       }
+               });
+               Progress pg = new Progress();
+               mainProgress.addProgress(pg, mainProgress.getMax());
+
+               VersionCheck updates = checkUpdates();
+
+               if (exitCode == 0) {
+                       switch (action) {
+                       case IMPORT:
+                               if (updates != null) {
+                                       // we consider it read
+                                       Instance.getInstance().setVersionChecked(); 
+                               }
+                               
+                               try {
+                                       exitCode = imprt(BasicReader.getUrl(urlString), pg);
+                               } catch (MalformedURLException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                                       exitCode = 1;
+                               }
+                               
+                               break;
+                       case EXPORT:
+                               if (updates != null) {
+                                       // we consider it read
+                                       Instance.getInstance().setVersionChecked(); 
+                               }
+                               
+                               OutputType exportType = OutputType.valueOfNullOkUC(sourceString, null);
+                               if (exportType == null) {
+                                       Instance.getInstance().getTraceHandler().error(new Exception(trans(StringId.OUTPUT_DESC, sourceString)));
+                                       exitCode = 1;
+                                       break;
+                               }
+                               
+                               exitCode = export(luid, exportType, target, pg);
+                               
+                               break;
+                       case CONVERT:
+                               if (updates != null) {
+                                       // we consider it read
+                                       Instance.getInstance().setVersionChecked(); 
+                               }
+                               
+                               OutputType convertType = OutputType.valueOfAllOkUC(sourceString, null);
+                               if (convertType == null) {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .error(new IOException(trans(StringId.ERR_BAD_OUTPUT_TYPE, sourceString)));
+
+                                       exitCode = 2;
+                                       break;
+                               }
+                               
+                               exitCode = convert(urlString, convertType, target,
+                                               plusInfo == null ? false : plusInfo, pg);
+                               
+                               break;
+                       case LIST:
+                               exitCode = list(sourceString);
+                               break;
+                       case SET_SOURCE:
+                               try {
+                                       Instance.getInstance().getLibrary().changeSource(luid, sourceString, pg);
+                               } catch (IOException e1) {
+                                       Instance.getInstance().getTraceHandler().error(e1);
+                                       exitCode = 21;
+                               }
+                               break;
+                       case SET_TITLE:
+                               try {
+                                       Instance.getInstance().getLibrary().changeTitle(luid, titleString, pg);
+                               } catch (IOException e1) {
+                                       Instance.getInstance().getTraceHandler().error(e1);
+                                       exitCode = 22;
+                               }
+                               break;
+                       case SET_AUTHOR:
+                               try {
+                                       Instance.getInstance().getLibrary().changeAuthor(luid, authorString, pg);
+                               } catch (IOException e1) {
+                                       Instance.getInstance().getTraceHandler().error(e1);
+                                       exitCode = 23;
+                               }
+                               break;
+                       case READ:
+                               if (luid == null || luid.isEmpty()) {
+                                       syntax(false);
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               try {
+                                       Integer chap = null;
+                                       if (chapString != null) {
+                                               try {
+                                                       chap = Integer.parseInt(chapString);
+                                               } catch (NumberFormatException e) {
+                                                       Instance.getInstance().getTraceHandler().error(new IOException(
+                                                                       "Chapter number cannot be parsed: " + chapString, e));
+                                                       exitCode = 2;
+                                                       break;
+                                               }
+                                       }
+                                       
+                                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                                       exitCode = read(lib.getStory(luid, null), chap);
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .error(new IOException("Failed to read book", e));
+                                       exitCode = 2;
+                               }
+
+                               break;
+                       case READ_URL:
+                               if (urlString == null || urlString.isEmpty()) {
+                                       syntax(false);
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               try {
+                                       Integer chap = null;
+                                       if (chapString != null) {
+                                               try {
+                                                       chap = Integer.parseInt(chapString);
+                                               } catch (NumberFormatException e) {
+                                                       Instance.getInstance().getTraceHandler().error(new IOException(
+                                                                       "Chapter number cannot be parsed: " + chapString, e));
+                                                       exitCode = 2;
+                                                       break;
+                                               }
+                                       }
+                                       
+                                       BasicSupport support = BasicSupport
+                                                       .getSupport(BasicReader.getUrl(urlString));
+                                       if (support == null) {
+                                               Instance.getInstance().getTraceHandler()
+                                                               .error("URL not supported: " + urlString);
+                                               exitCode = 2;
+                                               break;
+                                       }
+
+                                       exitCode = read(support.process(null), chap);
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .error(new IOException("Failed to read book", e));
+                                       exitCode = 2;
+                               }
+
+                               break;
+                       case SEARCH:
+                               page = page == null ? 1 : page;
+                               if (page < 0) {
+                                       Instance.getInstance().getTraceHandler().error("Incorrect page number");
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               item = item == null ? 0 : item;
+                               if (item < 0) {
+                                       Instance.getInstance().getTraceHandler().error("Incorrect item number");
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               if (searchOn == null) {
+                                       try {
+                                               search();
+                                       } catch (IOException e) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                               exitCode = 1;
+                                       }
+                               } else if (search != null) {
+                                       try {
+                                               searchKeywords(searchOn, search, page, item);
+                                       } catch (IOException e) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                               exitCode = 20;
+                                       }
+                               } else {
+                                       exitCode = 255;
+                               }
+
+                               break;
+                       case SEARCH_TAG:
+                               if (searchOn == null) {
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               page = page == null ? 1 : page;
+                               if (page < 0) {
+                                       Instance.getInstance().getTraceHandler().error("Incorrect page number");
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               item = item == null ? 0 : item;
+                               if (item < 0) {
+                                       Instance.getInstance().getTraceHandler().error("Incorrect item number");
+                                       exitCode = 255;
+                                       break;
+                               }
+
+                               try {
+                                       searchTags(searchOn, page, item,
+                                       tags.toArray(new Integer[] {}));
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                               }
+
+                               break;
+                       case HELP:
+                               syntax(true);
+                               exitCode = 0;
+                               break;
+                       case VERSION:
+                               if (updates != null) {
+                                       // we consider it read
+                                       Instance.getInstance().setVersionChecked(); 
+                               }
+                               
+                               System.out
+                                               .println(String.format("Fanfix version %s"
+                                                               + "%nhttps://github.com/nikiroo/fanfix/"
+                                                               + "%n\tWritten by Nikiroo",
+                                                               Version.getCurrentVersion()));
+                               break;
+                       case START:
+                               try {
+                                       start();
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                                       exitCode = 66;
+                               }
+                               break;
+                       case SERVER:
+                               try {
+                                       startServer();
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                               }
+                               
+                               break;
+                       case STOP_SERVER:
+                               // Can be given via "--remote XX XX XX"
+                               if (key == null)
+                                       key = Instance.getInstance().getConfig().getString(Config.SERVER_KEY);
+                               if (port == null)
+                                       port = Instance.getInstance().getConfig().getInteger(Config.SERVER_PORT);
+
+                               if (port == null) {
+                                       System.err.println("No port given nor configured in the config file");
+                                       exitCode = 15;
+                                       break;
+                               }
+                               try {
+                                       stopServer(key, host, port);
+                               } catch (SSLException e) {
+                                       Instance.getInstance().getTraceHandler().error(
+                                                       "Bad access key for remote library");
+                                       exitCode = 43;
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                                       exitCode = 44;
+                               }
+
+                               break;
+                       case REMOTE:
+                               exitCode = 255; // should not be reachable (REMOTE -> START)
+                               break;
+                       }
+               }
+
+               try {
+                       Instance.getInstance().getTempFiles().close();
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(new IOException(
+                                       "Cannot dispose of the temporary files", e));
+               }
+
+               if (exitCode == 255) {
+                       syntax(false);
+               }
+
+               exit(exitCode);
+       }
+       
+       /**
+        * A normal invocation of the program (without parameters or at least
+        * without "action" parameters).
+        * <p>
+        * You will probably want to override that one if you offer a user
+        * interface.
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void start() throws IOException {
+               new CliReader().listBooks(null);
+       }
+
+       /**
+        * Will check if updates are available, synchronously.
+        * <p>
+        * For this, it will simply forward the call to
+        * {@link Main#checkUpdates(String)} with a value of "nikiroo/fanfix".
+        * <p>
+        * You may want to override it so you call the forward method with the right
+        * parameters (or also if you want it to be asynchronous).
+        * 
+        * @return the newer version information or NULL if nothing new
+        */
+       protected VersionCheck checkUpdates() {
+               return checkUpdates("nikiroo/fanfix");
+       }
+
+       /**
+        * Will check if updates are available on a specific GitHub project.
+        * <p>
+        * Will be called by {@link Main#checkUpdates()}, but if you override that
+        * one you mall call it with another project.
+        * 
+        * @param githubProject
+        *            the GitHub project, for instance "nikiroo/fanfix"
+        * 
+        * @return the newer version information or NULL if nothing new
+        */
+       protected VersionCheck checkUpdates(String githubProject) {
+               try {
+                       VersionCheck updates = VersionCheck.check(githubProject,
+                                       Instance.getInstance().getTrans().getLocale());
+                       if (updates.isNewVersionAvailable()) {
+                               notifyUpdates(updates);
+                               return updates;
+                       }
+               } catch (IOException e) {
+                       // Maybe no internet. Do not report any update.
+               }
+
+               return null;
+       }
+
+       /**
+        * Notify the user about available updates.
+        * <p>
+        * Will only be called when a version is available.
+        * <p>
+        * Note that you can call {@link Instance#setVersionChecked()} on it if the
+        * user has read the information (by default, it is marked read only on
+        * certain other actions).
+        * 
+        * @param updates
+        *            the new version information
+        */
+       protected void notifyUpdates(VersionCheck updates) {
+               // Sent to syserr so not to cause problem if one tries to capture a
+               // story content in text mode
+               System.err.println(
+                               "A new version of the program is available at https://github.com/nikiroo/fanfix/releases");
+               System.err.println("");
+               for (Version v : updates.getNewer()) {
+                       System.err.println("\tVersion " + v);
+                       System.err.println("\t-------------");
+                       System.err.println("");
+                       for (String it : updates.getChanges().get(v)) {
+                               System.err.println("\t- " + it);
+                       }
+                       System.err.println("");
+               }
+       }
+       
+       /**
+        * Import the given resource into the {@link LocalLibrary}.
+        * 
+        * @param url
+        *            the resource to import
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the exit return code (0 = success)
+        */
+       protected static int imprt(URL url, Progress pg) {
+               try {
+                       MetaData meta = Instance.getInstance().getLibrary().imprt(url, pg);
+                       System.out.println(meta.getLuid() + ": \"" + meta.getTitle() + "\" imported.");
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+                       return 1;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Export the {@link Story} from the {@link LocalLibrary} to the given
+        * target.
+        * 
+        * @param luid
+        *            the story LUID
+        * @param type
+        *            the {@link OutputType} to use
+        * @param target
+        *            the target
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the exit return code (0 = success)
+        */
+       protected static int export(String luid, OutputType type, String target,
+                       Progress pg) {
+               try {
+                       Instance.getInstance().getLibrary().export(luid, type, target, pg);
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+                       return 4;
+               }
+
+               return 0;
+       }
+       
+       /**
+        * List the stories of the given source from the {@link LocalLibrary}
+        * (unless NULL is passed, in which case all stories will be listed).
+        * 
+        * @param source
+        *            the source to list the known stories of, or NULL to list all
+        *            stories
+        * 
+        * @return the exit return code (0 = success)
+        */
+       protected int list(String source) {
+               try {
+                       new CliReader().listBooks(source);
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+                       return 66;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Start the current reader for this {@link Story}.
+        * 
+        * @param story
+        *            the story to read
+        * @param chap
+        *            which {@link Chapter} to read (starting at 1), or NULL to get
+        *            the {@link Story} description
+        * 
+        * @return the exit return code (0 = success)
+        */
+       protected int read(Story story, Integer chap) {
+               if (story != null) {
+                       try {
+                               if (chap == null) {
+                                       new CliReader().listChapters(story);
+                               } else {
+                                       new CliReader().printChapter(story, chap);
+                               }
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler()
+                                               .error(new IOException("Failed to read book", e));
+                               return 2;
+                       }
+               } else {
+                       Instance.getInstance().getTraceHandler()
+                                       .error("Cannot find book: " + story);
+                       return 2;
+               }
+
+               return 0;
+       }
+
+       /**
+        * Convert the {@link Story} into another format.
+        * 
+        * @param urlString
+        *            the source {@link Story} to convert
+        * @param type
+        *            the {@link OutputType} to convert to
+        * @param target
+        *            the target file
+        * @param infoCover
+        *            TRUE to also export the cover and info file, even if the given
+        *            {@link OutputType} does not usually save them
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the exit return code (0 = success)
+        */
+       protected int convert(String urlString, OutputType type,
+                       String target, boolean infoCover, Progress pg) {
+               int exitCode = 0;
+
+               Instance.getInstance().getTraceHandler().trace("Convert: " + urlString);
+               String sourceName = urlString;
+               try {
+                       URL source = BasicReader.getUrl(urlString);
+                       sourceName = source.toString();
+                       if (sourceName.startsWith("file://")) {
+                               sourceName = sourceName.substring("file://".length());
+                       }
+
+                       try {
+                               BasicSupport support = BasicSupport.getSupport(source);
+
+                               if (support != null) {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .trace("Support found: " + support.getClass());
+                                       Progress pgIn = new Progress();
+                                       Progress pgOut = new Progress();
+                                       if (pg != null) {
+                                               pg.setMax(2);
+                                               pg.addProgress(pgIn, 1);
+                                               pg.addProgress(pgOut, 1);
+                                       }
+
+                                       Story story = support.process(pgIn);
+                                       try {
+                                               target = new File(target).getAbsolutePath();
+                                               BasicOutput.getOutput(type, infoCover, infoCover)
+                                                               .process(story, target, pgOut);
+                                       } catch (IOException e) {
+                                               Instance.getInstance().getTraceHandler()
+                                                               .error(new IOException(
+                                                                               trans(StringId.ERR_SAVING, target), e));
+                                               exitCode = 5;
+                                       }
+                               } else {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .error(new IOException(
+                                                                       trans(StringId.ERR_NOT_SUPPORTED, source)));
+
+                                       exitCode = 4;
+                               }
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(new IOException(
+                                               trans(StringId.ERR_LOADING, sourceName), e));
+                               exitCode = 3;
+                       }
+               } catch (MalformedURLException e) {
+                       Instance.getInstance().getTraceHandler().error(new IOException(trans(StringId.ERR_BAD_URL, sourceName), e));
+                       exitCode = 1;
+               }
+
+               return exitCode;
+       }
+
+       /**
+        * Display the correct syntax of the program to the user to stdout, or an
+        * error message if the syntax used was wrong on stderr.
+        * 
+        * @param showHelp
+        *            TRUE to show the syntax help, FALSE to show "syntax error"
+        */
+       protected void syntax(boolean showHelp) {
+               if (showHelp) {
+                       StringBuilder builder = new StringBuilder();
+                       for (SupportType type : SupportType.values()) {
+                               builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+                                               type.getDesc()));
+                               builder.append('\n');
+                       }
+
+                       String typesIn = builder.toString();
+                       builder.setLength(0);
+
+                       for (OutputType type : OutputType.values()) {
+                               builder.append(trans(StringId.ERR_SYNTAX_TYPE, type.toString(),
+                                               type.getDesc(true)));
+                               builder.append('\n');
+                       }
+
+                       String typesOut = builder.toString();
+
+                       System.out.println(trans(StringId.HELP_SYNTAX, typesIn, typesOut));
+               } else {
+                       System.err.println(trans(StringId.ERR_SYNTAX));
+               }
+       }
+
+       /**
+        * Starts a search operation (i.e., list the available web sites we can
+        * search on).
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       protected void search() throws IOException {
+               new CliReader().listSearchables();
+       }
+
+       /**
+        * Search for books by keywords on the given supported web site.
+        * 
+        * @param searchOn
+        *            the web site to search on
+        * @param search
+        *            the keyword to look for
+        * @param page
+        *            the page of results to get, or 0 to inquire about the number
+        *            of pages
+        * @param item
+        *            the index of the book we are interested by, or 0 to query
+        *            about how many books are in that page of results
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void searchKeywords(SupportType searchOn, String search,
+                       int page, Integer item) throws IOException {
+               new CliReader().searchBooksByKeyword(searchOn, search, page, item);
+       }
+
+       /**
+        * Search for books by tags on the given supported web site.
+        * 
+        * @param searchOn
+        *            the web site to search on
+        * @param page
+        *            the page of results to get, or 0 to inquire about the number
+        *            of pages
+        * @param item
+        *            the index of the book we are interested by, or 0 to query
+        *            about how many books are in that page of results
+        * @param tags
+        *            the tags to look for
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void searchTags(SupportType searchOn, Integer page, Integer item,
+                       Integer[] tags) throws IOException {
+               new CliReader().searchBooksByTag(searchOn, page, item, tags);
+       }
+
+       /**
+        * Start a Fanfix server.
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        * @throws SSLException
+        *             when the key was not accepted
+        */
+       private void startServer() throws IOException {
+               String mode = Instance.getInstance().getConfig()
+                               .getString(Config.SERVER_MODE, "fanfix");
+               if (mode.equals("fanfix")) {
+                       RemoteLibraryServer server = new RemoteLibraryServer();
+                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
+                       server.run();
+               } else if (mode.equals("http")) {
+                       WebLibraryServer server = new WebLibraryServer(false);
+                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
+                       server.run();
+               } else if (mode.equals("https")) {
+                       WebLibraryServer server = new WebLibraryServer(true);
+                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
+                       server.run();
+               } else {
+                       throw new IOException("Unknown server mode: " + mode);
+               }
+       }
+
+       /**
+        * Stop a running Fanfix server.
+        * 
+        * @param key
+        *            the key to contact the Fanfix server
+        * @param host
+        *            the host on which it runs (NULL means localhost)
+        * @param port
+        *            the port on which it runs
+        *            
+        * @throws IOException
+        *             in case of I/O errors
+        * @throws SSLException
+        *             when the key was not accepted
+        */
+       private void stopServer(
+                       String key, String host, Integer port)
+                       throws IOException, SSLException {
+               new RemoteLibrary(key, host, port).exit();
+       }
+
+       /**
+        * We are done and ready to exit.
+        * <p>
+        * By default, it will call {@link System#exit(int)} if the status is not 0.
+        * 
+        * @param status
+        *            the exit status
+        */
+       protected void exit(int status) {
+               if (status != 0) {
+                       System.exit(status);
+               }
+       }
+       
+       /**
+        * Simple shortcut method to call {link Instance#getTrans()#getString()}.
+        * 
+        * @param id
+        *            the ID to translate
+        * 
+        * @return the translated result
+        */
+       static private String trans(StringId id, Object... params) {
+               return Instance.getInstance().getTrans().getString(id, params);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Config.java b/src/be/nikiroo/fanfix/bundles/Config.java
new file mode 100644 (file)
index 0000000..c96ed22
--- /dev/null
@@ -0,0 +1,185 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The configuration options.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum Config {
+       
+       // Note: all hidden values are subject to be removed in a later version
+       
+       @Meta(description = "The language to use for in the program (example: en-GB, fr-BE...) or nothing for default system language (can be overwritten with the variable $LANG)",//
+       format = Format.LOCALE, list = { "en-GB", "fr-BE" })
+       LANG, //
+       @Meta(description = "File format options",//
+       group = true)
+       FILE_FORMAT, //
+       @Meta(description = "How to save non-images documents in the library",//
+       format = Format.FIXED_LIST, list = { "INFO_TEXT", "EPUB", "HTML", "TEXT" }, def = "INFO_TEXT")
+       FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "How to save images documents in the library",//
+       format = Format.FIXED_LIST, list = { "CBZ", "HTML" }, def = "CBZ")
+       FILE_FORMAT_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "How to save cover images",//
+       format = Format.FIXED_LIST, list = { "PNG", "JPG", "BMP" }, def = "PNG")
+       FILE_FORMAT_IMAGE_FORMAT_COVER, //
+       @Meta(description = "How to save content images",//
+       format = Format.FIXED_LIST, list = { "PNG", "JPG", "BMP" }, def = "JPG")
+       FILE_FORMAT_IMAGE_FORMAT_CONTENT, //
+
+       @Meta(description = "Cache management",//
+       group = true)
+       CACHE, //
+       @Meta(description = "The directory where to store temporary files; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "tmp/")
+       CACHE_DIR, //
+       @Meta(description = "The delay in hours after which a cached resource that is thought to change ~often is considered too old and triggers a refresh delay (or 0 for no cache, or -1 for infinite time)", //
+       format = Format.INT, def = "24")
+       CACHE_MAX_TIME_CHANGING, //
+       @Meta(description = "The delay in hours after which a cached resource that is thought to change rarely is considered too old and triggers a refresh delay (or 0 for no cache, or -1 for infinite time)", //
+       format = Format.INT, def = "720")
+       CACHE_MAX_TIME_STABLE, //
+
+       @Meta(description = "The directory where to get the default story covers; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "covers/")
+       DEFAULT_COVERS_DIR, //
+       @Meta(description = "The directory where to store the library (can be overriden by the environment variable \"BOOKS_DIR\"; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "$HOME/Books/")
+       LIBRARY_DIR, //
+
+       @Meta(description = "Remote library\nA remote library can be configured to fetch the stories from a remote Fanfix server",//
+       group = true)
+       REMOTE_LIBRARY, //
+       @Meta(description = "Use the remote Fanfix server configured here instead of the local library (if FALSE, the local library will be used instead)",//
+       format = Format.BOOLEAN, def = "false")
+       REMOTE_LIBRARY_ENABLED, //
+       @Meta(description = "The remote Fanfix server to connect to (fanfix://, http://, https:// -- if not specified, fanfix:// is assumed)",//
+       format = Format.STRING)
+       REMOTE_LIBRARY_HOST, //
+       @Meta(description = "The port to use for the remote Fanfix server",//
+       format = Format.INT, def = "58365")
+       REMOTE_LIBRARY_PORT, //
+       @Meta(description = "The key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
+       format = Format.PASSWORD)
+       REMOTE_LIBRARY_KEY, //
+
+       @Meta(description = "Network configuration",//
+       group = true)
+       NETWORK, //
+       @Meta(description = "The user-agent to use to download files",//
+       def = "Mozilla/5.0 (X11; Linux x86_64; rv:44.0) Gecko/20100101 Firefox/44.0 -- ELinks/0.9.3 (Linux 2.6.11 i686; 80x24) -- Fanfix (https://github.com/nikiroo/fanfix/)")
+       NETWORK_USER_AGENT, //
+       @Meta(description = "The proxy server to use under the format 'user:pass@proxy:port', 'user@proxy:port', 'proxy:port' or ':' alone (system proxy); an empty String means no proxy",//
+       format = Format.STRING, def = "")
+       NETWORK_PROXY, //
+       @Meta(description = "If the last update check was done at least that many days ago, check for updates at startup (-1 for 'no checks')", //
+       format = Format.INT, def = "1")
+       NETWORK_UPDATE_INTERVAL, //
+
+       @Meta(description = "Remote Server configuration\nNote that the key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
+       group = true)
+       SERVER, //
+       @Meta(description = "Remote Server mode: you can use the fanfix protocol (which is encrypted), http (which is not) or https (which requires a keystore.jks file)",//
+       format = Format.FIXED_LIST, list = { "fanfix", "http", "https" }, def = "fanfix")
+       SERVER_MODE,
+       @Meta(description = "The port on which we can start the server (must be a valid port, from 1 to 65535)", //
+       format = Format.INT, def = "58365")
+       SERVER_PORT, //
+       @Meta(description = "A keystore.jks file, required to use HTTPS (the server will refuse to start in HTTPS mode without this file)", //
+       format = Format.STRING, def = "")
+       SERVER_SSL_KEYSTORE,
+       @Meta(description = "The pass phrase required to open the keystore.jks file (required for HTTPS mode)", //
+       format = Format.PASSWORD, def = "")
+       SERVER_SSL_KEYSTORE_PASS,
+       @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty -- is used to encrypt the traffic in fanfix mode (even if empty, traffic will be encrypted in fanfix mode), and used as a password for HTTP (clear text protocol) and HTTPS modes",//
+       format = Format.PASSWORD, def = "")
+       SERVER_KEY, //
+       @Meta(description = "Allow write access to the clients (download story, move story...) without RW subkeys", //
+       format = Format.BOOLEAN, def = "true")
+       SERVER_RW, //
+       @Meta(description = "If not empty, only the EXACT listed sources will be available for clients without BL subkeys",//
+       array = true, format = Format.STRING, def = "")
+       SERVER_WHITELIST, //
+       @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey is used as a login for HTTP (clear text protocol) and HTTPS modes", //
+       array = true, format = Format.STRING, def = "")
+       SERVER_ALLOWED_SUBKEYS, //
+       @Meta(description = "The maximum size of the cache, in MegaBytes, for HTTP and HTTPS servers", //
+       format = Format.INT, def = "100")
+       SERVER_MAX_CACHE_MB,
+
+       @Meta(description = "DEBUG options",//
+       group = true)
+       DEBUG, //
+       @Meta(description = "Show debug information on errors",//
+       format = Format.BOOLEAN, def = "false")
+       DEBUG_ERR, //
+       @Meta(description = "Show debug trace information",//
+       format = Format.BOOLEAN, def = "false")
+       DEBUG_TRACE, //
+
+       @Meta(description = "Internal configuration\nThose options are internal to the program and should probably not be changed",//
+       hidden = true, group = true)
+       CONF, //
+       @Meta(description = "LaTeX configuration",//
+       hidden = true, group = true)
+       CONF_LATEX_LANG, //
+       @Meta(description = "LaTeX output language (full name) for \"English\"",//
+       hidden = true, format = Format.STRING, def = "english")
+       CONF_LATEX_LANG_EN, //
+       @Meta(description = "LaTeX output language (full name) for \"French\"",//
+       hidden = true, format = Format.STRING, def = "french")
+       CONF_LATEX_LANG_FR, //
+       @Meta(description = "other 'by' prefixes before author name, used to identify the author",//
+       hidden = true, array = true, format = Format.STRING, def = "\"by\",\"par\",\"de\",\"©\",\"(c)\"")
+       CONF_BYS, //
+       @Meta(description = "List of languages codes used for chapter identification (should not be changed)", //
+       hidden = true, array = true, format = Format.STRING, def = "\"EN\",\"FR\"")
+       CONF_CHAPTER, //
+       @Meta(description = "Chapter identification string in English, used to identify a starting chapter in text mode",//
+       hidden = true, format = Format.STRING, def = "Chapter")
+       CONF_CHAPTER_EN, //
+       @Meta(description = "Chapter identification string in French, used to identify a starting chapter in text mode",//
+       hidden = true, format = Format.STRING, def = "Chapitre")
+       CONF_CHAPTER_FR, //
+
+       @Meta(description = "YiffStar/SoFurry credentials\nYou can give your YiffStar credentials here to have access to all the stories, though it should not be necessary anymore (some stories used to beblocked for anonymous viewers)",//
+       group = true)
+       LOGIN_YIFFSTAR, //
+       @Meta(description = "Your YiffStar/SoFurry login",//
+       format = Format.STRING)
+       LOGIN_YIFFSTAR_USER, //
+       @Meta(description = "Your YiffStar/SoFurry password",//
+       format = Format.PASSWORD)
+       LOGIN_YIFFSTAR_PASS, //
+
+       @Meta(description = "FimFiction APIKEY credentials\nFimFiction can be queried via an API, but requires an API key to do that. One has been created for this program, but if you have another API key you can set it here. You can also set a login and password instead, in that case, a new API key will be generated (and stored) if you still haven't set one.",//
+       group = true)
+       LOGIN_FIMFICTION_APIKEY, //
+       @Meta(description = "The login of the API key used to create a new token from FimFiction", //
+       format = Format.STRING)
+       LOGIN_FIMFICTION_APIKEY_CLIENT_ID, //
+       @Meta(description = "The password of the API key used to create a new token from FimFiction", //
+       format = Format.PASSWORD)
+       LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET, //
+       @Meta(description = "Do not use the new API, even if we have a token, and force HTML scraping",//
+       format = Format.BOOLEAN, def = "false")
+       LOGIN_FIMFICTION_APIKEY_FORCE_HTML, //
+       @Meta(description = "The token required to use the beta APIv2 from FimFiction (see APIKEY_CLIENT_* if you want to generate a new one from your own API key)", //
+       format = Format.PASSWORD, def = "Bearer WnZ5oHlzQoDocv1GcgHfcoqctHkSwL-D")
+       LOGIN_FIMFICTION_APIKEY_TOKEN, //
+       
+       @Meta(description = "e621.net credentials\nYou can give your e621.net credentials here to have access to all the comics and ignore the default blacklist",//
+       group = true)
+       LOGIN_E621, //
+       @Meta(description = "Your e621.net login",//
+       format = Format.STRING)
+       LOGIN_E621_LOGIN, //
+       @Meta(description = "Your e621.net API KEY",//
+       format = Format.PASSWORD)
+       LOGIN_E621_APIKEY, //
+}
diff --git a/src/be/nikiroo/fanfix/bundles/ConfigBundle.java b/src/be/nikiroo/fanfix/bundles/ConfigBundle.java
new file mode 100644 (file)
index 0000000..ce72b3d
--- /dev/null
@@ -0,0 +1,41 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * This class manages the configuration of the application.
+ * 
+ * @author niki
+ */
+public class ConfigBundle extends Bundle<Config> {
+       /**
+        * Create a new {@link ConfigBundle}.
+        */
+       public ConfigBundle() {
+               super(Config.class, Target.config5, null);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new ConfigBundle().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "Configuration options";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringId.java b/src/be/nikiroo/fanfix/bundles/StringId.java
new file mode 100644 (file)
index 0000000..9772248
--- /dev/null
@@ -0,0 +1,151 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.Meta;
+
+/**
+ * The {@link Enum} representing textual information to be translated to the
+ * user as a key.
+ * 
+ * Note that each key that should be translated <b>must</b> be annotated with a
+ * {@link Meta} annotation.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum StringId {
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need NO translation.
+        */
+       NULL, //
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need a real translation but still don't have a key.
+        */
+       DUMMY, //
+       @Meta(info = "%s = supported input, %s = supported output", description = "help message for the syntax")
+       HELP_SYNTAX, //
+       @Meta(description = "syntax error message")
+       ERR_SYNTAX, //
+       @Meta(info = "%s = support name, %s = support desc", description = "an input or output support type description")
+       ERR_SYNTAX_TYPE, //
+       @Meta(info = "%s = input string", description = "Error when retrieving data")
+       ERR_LOADING, //
+       @Meta(info = "%s = save target", description = "Error when saving to given target")
+       ERR_SAVING, //
+       @Meta(info = "%s = bad output format", description = "Error when unknown output format")
+       ERR_BAD_OUTPUT_TYPE, //
+       @Meta(info = "%s = input string", description = "Error when converting input to URL/File")
+       ERR_BAD_URL, //
+       @Meta(info = "%s = input url", description = "URL/File not supported")
+       ERR_NOT_SUPPORTED, //
+       @Meta(info = "%s = cover URL", description = "Failed to download cover : %s")
+       ERR_BS_NO_COVER, //
+       @Meta(def = "`", info = "single char", description = "Canonical OPEN SINGLE QUOTE char (for instance: ‘)")
+       OPEN_SINGLE_QUOTE, //
+       @Meta(def = "‘", info = "single char", description = "Canonical CLOSE SINGLE QUOTE char (for instance: ’)")
+       CLOSE_SINGLE_QUOTE, //
+       @Meta(def = "“", info = "single char", description = "Canonical OPEN DOUBLE QUOTE char (for instance: “)")
+       OPEN_DOUBLE_QUOTE, //
+       @Meta(def = "”", info = "single char", description = "Canonical CLOSE DOUBLE QUOTE char (for instance: ”)")
+       CLOSE_DOUBLE_QUOTE, //
+       @Meta(def = "Description", description = "Name of the description fake chapter")
+       DESCRIPTION, //
+       @Meta(def = "Chapter %d: %s", info = "%d = number, %s = name", description = "Name of a chapter with a name")
+       CHAPTER_NAMED, //
+       @Meta(def = "Chapter %d", info = "%d = number, %s = name", description = "Name of a chapter without name")
+       CHAPTER_UNNAMED, //
+       @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
+       INPUT_DESC, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_EPUB, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_TEXT, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_INFO_TEXT, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_FANFICTION, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_FIMFICTION, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_MANGAFOX, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_E621, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_E_HENTAI, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_YIFFSTAR, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_CBZ, //
+       @Meta(description = "Description of this input type")
+       INPUT_DESC_HTML, //
+       @Meta(info = "%s = type", description = "Default description when the type is not known by i18n")
+       OUTPUT_DESC, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_EPUB, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_TEXT, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_INFO_TEXT, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_CBZ, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_HTML, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_LATEX, //
+       @Meta(description = "Description of this output type")
+       OUTPUT_DESC_SYSOUT, //
+       @Meta(group = true, info = "%s = type", description = "Default description when the type is not known by i18n")
+       OUTPUT_DESC_SHORT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_EPUB, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_TEXT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_INFO_TEXT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_CBZ, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_LATEX, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_SYSOUT, //
+       @Meta(description = "Short description of this output type")
+       OUTPUT_DESC_SHORT_HTML, //
+       @Meta(info = "%s = the unknown 2-code language", description = "Error message for unknown 2-letter LaTeX language code")
+       LATEX_LANG_UNKNOWN, //
+       @Meta(def = "by", description = "'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection")
+       BY, //
+
+       ;
+
+       /**
+        * Write the header found in the configuration <tt>.properties</tt> file of
+        * this {@link Bundle}.
+        * 
+        * @param writer
+        *            the {@link Writer} to write the header in
+        * @param name
+        *            the file name
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public void writeHeader(Writer writer, String name)
+                       throws IOException {
+               writer.write("# " + name + " translation file (UTF-8)\n");
+               writer.write("# \n");
+               writer.write("# Note that any key can be doubled with a _NOUTF suffix\n");
+               writer.write("# to use when the NOUTF env variable is set to 1\n");
+               writer.write("# \n");
+               writer.write("# Also, the comments always refer to the key below them.\n");
+               writer.write("# \n");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdBundle.java b/src/be/nikiroo/fanfix/bundles/StringIdBundle.java
new file mode 100644 (file)
index 0000000..b9a0d79
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the translation resources of the application (Core).
+ * 
+ * @author niki
+ */
+public class StringIdBundle extends TransBundle<StringId> {
+       /**
+        * Create a translation service for the given language (will fall back to
+        * the default one if not found).
+        * 
+        * @param lang
+        *            the language to use
+        */
+       public StringIdBundle(String lang) {
+               super(StringId.class, Target.resources_core, lang);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new StringIdBundle(null).updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGui.java b/src/be/nikiroo/fanfix/bundles/StringIdGui.java
new file mode 100644 (file)
index 0000000..c109f42
--- /dev/null
@@ -0,0 +1,199 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import be.nikiroo.utils.resources.Bundle;
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The {@link Enum} representing textual information to be translated to the
+ * user as a key.
+ * 
+ * Note that each key that should be translated <b>must</b> be annotated with a
+ * {@link Meta} annotation.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum StringIdGui {
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need NO translation.
+        */
+       NULL, //
+       /**
+        * A special key used for technical reasons only, without annotations so it
+        * is not visible in <tt>.properties</tt> files.
+        * <p>
+        * Use it when you need a real translation but still don't have a key.
+        */
+       DUMMY, //
+       @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library", info = "%s = current Fanfix version")
+       // The titles/subtitles:
+       TITLE_LIBRARY, //
+       @Meta(def = "Fanfix %s", format = Format.STRING, description = "the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)", info = "%s = current Fanfix version, %s = library name")
+       TITLE_LIBRARY_WITH_NAME, //
+       @Meta(def = "Fanfix Configuration", format = Format.STRING, description = "the title of the configuration window of Fanfix, also the name of the menu button")
+       TITLE_CONFIG, //
+       @Meta(def = "This is where you configure the options of the program.", format = Format.STRING, description = "the subtitle of the configuration window of Fanfix")
+       SUBTITLE_CONFIG, //
+       @Meta(def = "UI Configuration", format = Format.STRING, description = "the title of the UI configuration window of Fanfix, also the name of the menu button")
+       TITLE_CONFIG_UI, //
+       @Meta(def = "This is where you configure the graphical appearence of the program.", format = Format.STRING, description = "the subtitle of the UI configuration window of Fanfix")
+       SUBTITLE_CONFIG_UI, //
+       @Meta(def = "Save", format = Format.STRING, description = "the title of the 'save to/export to' window of Fanfix")
+       TITLE_SAVE, //
+       @Meta(def = "Moving story", format = Format.STRING, description = "the title of the 'move to' window of Fanfix")
+       TITLE_MOVE_TO, //
+       @Meta(def = "Move to:", format = Format.STRING, description = "the subtitle of the 'move to' window of Fanfix")
+       SUBTITLE_MOVE_TO, //
+       @Meta(def = "Delete story", format = Format.STRING, description = "the title of the 'delete' window of Fanfix")
+       TITLE_DELETE, //
+       @Meta(def = "Delete %s: %s", format = Format.STRING, description = "the subtitle of the 'delete' window of Fanfix", info = "%s = LUID of the story, %s = title of the story")
+       SUBTITLE_DELETE, //
+       @Meta(def = "Library error", format = Format.STRING, description = "the title of the 'library error' dialogue")
+       TITLE_ERROR_LIBRARY, //
+       @Meta(def = "Importing from URL", format = Format.STRING, description = "the title of the 'import URL' dialogue")
+       TITLE_IMPORT_URL, //
+       @Meta(def = "URL of the story to import:", format = Format.STRING, description = "the subtitle of the 'import URL' dialogue")
+       SUBTITLE_IMPORT_URL, //
+       @Meta(def = "Error", format = Format.STRING, description = "the title of general error dialogues")
+       TITLE_ERROR, //
+       @Meta(def = "%s: %s", format = Format.STRING, description = "the title of a story for the properties dialogue, the viewers...", info = "%s = LUID of the story, %s = title of the story")
+       TITLE_STORY, //
+
+       //
+
+       @Meta(def = "A new version of the program is available at %s", format = Format.STRING, description = "HTML text used to notify of a new version", info = "%s = url link in HTML")
+       NEW_VERSION_AVAILABLE, //
+       @Meta(def = "Updates available", format = Format.STRING, description = "text used as title for the update dialogue")
+       NEW_VERSION_TITLE, //
+       @Meta(def = "Version %s", format = Format.STRING, description = "HTML text used to specify a newer version title and number, used for each version newer than the current one", info = "%s = the newer version number")
+       NEW_VERSION_VERSION, //
+       @Meta(def = "%s words", format = Format.STRING, description = "show the number of words of a book", info = "%s = the number")
+       BOOK_COUNT_WORDS, //
+       @Meta(def = "%s images", format = Format.STRING, description = "show the number of images of a book", info = "%s = the number")
+       BOOK_COUNT_IMAGES, //
+       @Meta(def = "%s stories", format = Format.STRING, description = "show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)", info = "%s = the number")
+       BOOK_COUNT_STORIES, //
+
+       // Menu (and popup) items:
+
+       @Meta(def = "File", format = Format.STRING, description = "the file menu")
+       MENU_FILE, //
+       @Meta(def = "Exit", format = Format.STRING, description = "the file/exit menu button")
+       MENU_FILE_EXIT, //
+       @Meta(def = "Import File...", format = Format.STRING, description = "the file/import_file menu button")
+       MENU_FILE_IMPORT_FILE, //
+       @Meta(def = "Import URL...", format = Format.STRING, description = "the file/import_url menu button")
+       MENU_FILE_IMPORT_URL, //
+       @Meta(def = "Save as...", format = Format.STRING, description = "the file/export menu button")
+       MENU_FILE_EXPORT, //
+       @Meta(def = "Move to", format = Format.STRING, description = "the file/move to menu button")
+       MENU_FILE_MOVE_TO, //
+       @Meta(def = "Set author", format = Format.STRING, description = "the file/set author menu button")
+       MENU_FILE_SET_AUTHOR, //
+       @Meta(def = "New source...", format = Format.STRING, description = "the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source")
+       MENU_FILE_MOVE_TO_NEW_TYPE, //
+       @Meta(def = "New author...", format = Format.STRING, description = "the file/move to/new author menu button, that will trigger a dialogue to create a new author")
+       MENU_FILE_MOVE_TO_NEW_AUTHOR, //
+       @Meta(def = "Rename...", format = Format.STRING, description = "the file/rename menu item, that will trigger a dialogue to ask for a new title for the story")
+       MENU_FILE_RENAME, //
+       @Meta(def = "Properties", format = Format.STRING, description = "the file/Properties menu item, that will trigger a dialogue to show the properties of the story")
+       MENU_FILE_PROPERTIES, //
+       @Meta(def = "Open", format = Format.STRING, description = "the file/open menu item, that will open the story or fake-story (an author or a source/type)")
+       MENU_FILE_OPEN, //
+       @Meta(def = "Edit", format = Format.STRING, description = "the edit menu")
+       MENU_EDIT, //
+       @Meta(def = "Prefetch to cache", format = Format.STRING, description = "the edit/send to cache menu button, to download the story into the cache if not already done")
+       MENU_EDIT_DOWNLOAD_TO_CACHE, //
+       @Meta(def = "Clear cache", format = Format.STRING, description = "the clear cache menu button, to clear the cache for a single book")
+       MENU_EDIT_CLEAR_CACHE, //
+       @Meta(def = "Redownload", format = Format.STRING, description = "the edit/redownload menu button, to download the latest version of the book")
+       MENU_EDIT_REDOWNLOAD, //
+       @Meta(def = "Delete", format = Format.STRING, description = "the edit/delete menu button")
+       MENU_EDIT_DELETE, //
+       @Meta(def = "Set as cover for source", format = Format.STRING, description = "the edit/Set as cover for source menu button")
+       MENU_EDIT_SET_COVER_FOR_SOURCE, //
+       @Meta(def = "Set as cover for author", format = Format.STRING, description = "the edit/Set as cover for author menu button")
+       MENU_EDIT_SET_COVER_FOR_AUTHOR, //
+       @Meta(def = "Search", format = Format.STRING, description = "the search menu to open the earch stories on one of the searchable websites")
+       MENU_SEARCH,
+       @Meta(def = "View", format = Format.STRING, description = "the view menu")
+       MENU_VIEW, //
+       @Meta(def = "Word count", format = Format.STRING, description = "the view/word_count menu button, to show the word/image/story count as secondary info")
+       MENU_VIEW_WCOUNT, //
+       @Meta(def = "Author", format = Format.STRING, description = "the view/author menu button, to show the author as secondary info")
+       MENU_VIEW_AUTHOR, //
+       @Meta(def = "Sources", format = Format.STRING, description = "the sources menu, to select the books from a specific source; also used as a title for the source books")
+       MENU_SOURCES, //
+       @Meta(def = "Authors", format = Format.STRING, description = "the authors menu, to select the books of a specific author; also used as a title for the author books")
+       MENU_AUTHORS, //
+       @Meta(def = "Options", format = Format.STRING, description = "the options menu, to configure Fanfix from the GUI")
+       MENU_OPTIONS, //
+       @Meta(def = "All", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, by group (one book = one group)")
+       MENU_XXX_ALL_GROUPED, //
+       @Meta(def = "Listing", format = Format.STRING, description = "a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)")
+       MENU_XXX_ALL_LISTING, //
+       @Meta(def = "[unknown]", format = Format.STRING, description = "a special menu button to select the books without author")
+       MENU_AUTHORS_UNKNOWN, //
+
+       // Progress names
+       @Meta(def = "Reload books", format = Format.STRING, description = "progress bar caption for the 'reload books' step of all outOfUi operations")
+       PROGRESS_OUT_OF_UI_RELOAD_BOOKS, //
+       @Meta(def = "Change the source of the book to %s", format = Format.STRING, description = "progress bar caption for the 'change source' step of the ReDownload operation", info = "%s = new source name")
+       PROGRESS_CHANGE_SOURCE, //
+
+       // Error messages
+       @Meta(def = "An error occured when contacting the library", format = Format.STRING, description = "default description if the error is not known")
+       ERROR_LIB_STATUS, //
+       @Meta(def = "You are not allowed to access this library", format = Format.STRING, description = "library access not allowed")
+       ERROR_LIB_STATUS_UNAUTHORIZED, //
+       @Meta(def = "Library not valid", format = Format.STRING, description = "the library is invalid (not correctly set up)")
+       ERROR_LIB_STATUS_INVALID, //
+       @Meta(def = "Library currently unavailable", format = Format.STRING, description = "the library is out of commission")
+       ERROR_LIB_STATUS_UNAVAILABLE, //
+       @Meta(def = "Cannot open the selected book", format = Format.STRING, description = "cannot open the book, internal or external viewer")
+       ERROR_CANNOT_OPEN, //
+       @Meta(def = "URL not supported: %s", format = Format.STRING, description = "URL is not supported by Fanfix", info = "%s = URL")
+       ERROR_URL_NOT_SUPPORTED, //
+       @Meta(def = "Failed to import %s:\n%s", format = Format.STRING, description = "cannot import the URL", info = "%s = URL, %s = reasons")
+       ERROR_URL_IMPORT_FAILED,
+
+       // Others
+       @Meta(def = "&nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters")
+       CHAPTER_HTML_UNNAMED, //
+       @Meta(def = "&nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>: %s", format = Format.STRING, description = "(html) the chapter progression value used on the viewers", info = "%d = chapter number, %d = total chapters, %s = chapter name")
+       CHAPTER_HTML_NAMED, //
+       @Meta(def = "Image %d / %d", format = Format.STRING, description = "(NO html) the chapter progression value used on the viewers", info = "%d = current image number, %d = total images")
+       IMAGE_PROGRESSION, //
+       
+       ;
+
+       /**
+        * Write the header found in the configuration <tt>.properties</tt> file of
+        * this {@link Bundle}.
+        * 
+        * @param writer
+        *            the {@link Writer} to write the header in
+        * @param name
+        *            the file name
+        * 
+        * @throws IOException
+        *             in case of IO error
+        */
+       static public void writeHeader(Writer writer, String name)
+                       throws IOException {
+               writer.write("# " + name + " translation file (UTF-8)\n");
+               writer.write("# \n");
+               writer.write("# Note that any key can be doubled with a _NOUTF suffix\n");
+               writer.write("# to use when the NOUTF env variable is set to 1\n");
+               writer.write("# \n");
+               writer.write("# Also, the comments always refer to the key below them.\n");
+               writer.write("# \n");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java b/src/be/nikiroo/fanfix/bundles/StringIdGuiBundle.java
new file mode 100644 (file)
index 0000000..c036381
--- /dev/null
@@ -0,0 +1,40 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the translation resources of the application (GUI).
+ * 
+ * @author niki
+ */
+public class StringIdGuiBundle extends TransBundle<StringIdGui> {
+       /**
+        * Create a translation service for the given language (will fall back to
+        * the default one if not found).
+        * 
+        * @param lang
+        *            the language to use
+        */
+       public StringIdGuiBundle(String lang) {
+               super(StringIdGui.class, Target.resources_gui, lang);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new StringIdGuiBundle(null).updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/Target.java b/src/be/nikiroo/fanfix/bundles/Target.java
new file mode 100644 (file)
index 0000000..64284c6
--- /dev/null
@@ -0,0 +1,27 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * The type of configuration information the associated {@link Bundle} will
+ * convey.
+ * <p>
+ * Those values can change when the file is not compatible anymore.
+ * 
+ * @author niki
+ */
+public enum Target {
+       /**
+        * Configuration options that the user can change in the
+        * <tt>.properties</tt> file
+        */
+       config5,
+       /** Translation resources (Core) */
+       resources_core,
+       /** Translation resources (GUI) */
+       resources_gui,
+       /** UI resources (from colours to behaviour) */
+       ui,
+       /** Description of UI resources. */
+       ui_description,
+}
diff --git a/src/be/nikiroo/fanfix/bundles/UiConfig.java b/src/be/nikiroo/fanfix/bundles/UiConfig.java
new file mode 100644 (file)
index 0000000..2122ccf
--- /dev/null
@@ -0,0 +1,59 @@
+package be.nikiroo.fanfix.bundles;
+
+import be.nikiroo.utils.resources.Meta;
+import be.nikiroo.utils.resources.Meta.Format;
+
+/**
+ * The configuration options.
+ * 
+ * @author niki
+ */
+@SuppressWarnings("javadoc")
+public enum UiConfig {
+       @Meta(description = "The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator",//
+       format = Format.DIRECTORY, def = "tmp-reader/")
+       CACHE_DIR_LOCAL_READER, //
+       @Meta(description = "How to save the cached stories for the GUI Reader (non-images documents) -- those files will be sent to the reader",//
+       format = Format.COMBO_LIST, list = { "INFO_TEXT", "EPUB", "HTML", "TEXT" }, def = "EPUB")
+       GUI_NON_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "How to save the cached stories for the GUI Reader (images documents) -- those files will be sent to the reader",//
+       format = Format.COMBO_LIST, list = { "CBZ", "HTML" }, def = "CBZ")
+       GUI_IMAGES_DOCUMENT_TYPE, //
+       @Meta(description = "Use the internal reader for images documents",//
+       format = Format.BOOLEAN, def = "true")
+       IMAGES_DOCUMENT_USE_INTERNAL_READER, //
+       @Meta(description = "The external viewer for images documents (or empty to use the system default program for the given file type)",//
+       format = Format.STRING)
+       IMAGES_DOCUMENT_READER, //
+       @Meta(description = "Use the internal reader for non-images documents",//
+       format = Format.BOOLEAN, def = "true")
+       NON_IMAGES_DOCUMENT_USE_INTERNAL_READER, //
+       @Meta(description = "The external viewer for non-images documents (or empty to use the system default program for the given file type)",//
+       format = Format.STRING)
+       NON_IMAGES_DOCUMENT_READER, //
+       @Meta(description = "The icon to use for the program",//
+       format = Format.FIXED_LIST, def = "default", list = { "default", "alternative", "magic-book", "pony-book", "pony-library" })
+       PROGRAM_ICON, //
+       //
+       // GUI settings (hidden in config)
+       //
+       @Meta(description = "Show the side panel by default",//
+       hidden = true, format = Format.BOOLEAN, def = "true")
+       SHOW_SIDE_PANEL, //
+       @Meta(description = "Show the details panel by default",//
+       hidden = true, format = Format.BOOLEAN, def = "true")
+       SHOW_DETAILS_PANEL, //
+       @Meta(description = "Show thumbnails by default in the books view",//
+       hidden = true, format = Format.BOOLEAN, def = "false")
+       SHOW_THUMBNAILS, //
+       @Meta(description = "Show a words/images count instead of the author by default in the books view",//
+       hidden = true, format = Format.BOOLEAN, def = "false")
+       SHOW_WORDCOUNT, //
+       //
+       // Deprecated
+       //
+       @Meta(description = "The background colour of the library if you don't like the default system one",//
+       hidden = true, format = Format.COLOR)
+       @Deprecated
+       BACKGROUND_COLOR, //
+}
diff --git a/src/be/nikiroo/fanfix/bundles/UiConfigBundle.java b/src/be/nikiroo/fanfix/bundles/UiConfigBundle.java
new file mode 100644 (file)
index 0000000..8b2c008
--- /dev/null
@@ -0,0 +1,39 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.Bundle;
+
+/**
+ * This class manages the configuration of UI of the application (colours and
+ * behaviour)
+ * 
+ * @author niki
+ */
+public class UiConfigBundle extends Bundle<UiConfig> {
+       public UiConfigBundle() {
+               super(UiConfig.class, Target.ui, new UiConfigBundleDesc());
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new UiConfigBundle().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "UI configuration options";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/UiConfigBundleDesc.java b/src/be/nikiroo/fanfix/bundles/UiConfigBundleDesc.java
new file mode 100644 (file)
index 0000000..da42950
--- /dev/null
@@ -0,0 +1,39 @@
+package be.nikiroo.fanfix.bundles;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.utils.resources.TransBundle;
+
+/**
+ * This class manages the configuration of UI of the application (colours and
+ * behaviour)
+ * 
+ * @author niki
+ */
+public class UiConfigBundleDesc extends TransBundle<UiConfig> {
+       public UiConfigBundleDesc() {
+               super(UiConfig.class, Target.ui_description);
+       }
+
+       /**
+        * Update resource file.
+        * 
+        * @param args
+        *            not used
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public static void main(String[] args) throws IOException {
+               String path = new File(".").getAbsolutePath()
+                               + "/src/be/nikiroo/fanfix/bundles/";
+               new UiConfigBundleDesc().updateFile(path);
+               System.out.println("Path updated: " + path);
+       }
+
+       @Override
+       protected String getBundleDisplayName() {
+               return "UI configuration options description";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/bundles/package-info.java b/src/be/nikiroo/fanfix/bundles/package-info.java
new file mode 100644 (file)
index 0000000..80cdd15
--- /dev/null
@@ -0,0 +1,8 @@
+/**
+ * This package encloses the different 
+ * {@link be.nikiroo.utils.resources.Bundle} and their associated 
+ * {@link java.lang.Enum}s used by the application.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.bundles;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/bundles/resources_core.properties b/src/be/nikiroo/fanfix/bundles/resources_core.properties
new file mode 100644 (file)
index 0000000..dc7881a
--- /dev/null
@@ -0,0 +1,207 @@
+# United Kingdom (en_GB) resources_core translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# help message for the syntax
+# (FORMAT: STRING) 
+HELP_SYNTAX = Valid options:\n\
+\t--import [URL]: import into library\n\
+\t--export [id] [output_type] [target]: export story to target\n\
+\t--convert [URL] [output_type] [target] (+info): convert URL into target\n\
+\t--read [id] ([chapter number]): read the given story from the library\n\
+\t--read-url [URL] ([chapter number]): convert on the fly and read the \n\
+\t\tstory, without saving it\n\
+\t--search WEBSITE [free text] ([page] ([item])): search for the given terms, show the\n\
+\t\tgiven page (page 0 means "how many page do we have", starts at page 1)\n\
+\t--search-tag WEBSITE ([tag 1] [tag2...] ([page] ([item]))): list the known tags or \n\
+\t\tsearch the stories for the given tag(s), show the given page of results\n\
+\t--search: list the supported websites (where)\n\
+\t--search [where] [keywords] (page [page]) (item [item]): search on the supported \n\
+\t\twebsite and display the given results page of stories it found, or the story \n\
+\t\tdetails if asked\n\
+\t--search-tag [where]: list all the tags supported by this website\n\
+\t--search-tag [index 1]... (page [page]) (item [item]): search for the given stories or \n\
+\t\tsubtags, tag by tag, and display information about a specific page of results or \n\
+\t\tabout a specific item if requested\n\
+\t--list ([type]) : list the stories present in the library\n\
+\t--set-source [id] [new source]: change the source of the given story\n\
+\t--set-title [id] [new title]: change the title of the given story\n\
+\t--set-author [id] [new author]: change the author of the given story\n\
+\t--set-reader [reader type]: set the reader type to CLI, TUI or GUI for \n\
+\t\tthis command\n\
+\t--server: start the server mode (see config file for parameters)\n\
+\t--stop-server: stop the remote server running on this port\n\
+\t\tif any (key must be set to the same value)\n\
+\t--remote [key] [host] [port]: select this remote server to get \n\
+\t\t(or update or...) the stories from (key must be set to the \n\
+\t\tsame value)\n\
+\t--help: this help message\n\
+\t--version: return the version of the program\n\
+\n\
+Supported input types:\n\
+%s\n\
+\n\
+Supported output types:\n\
+%s
+# syntax error message
+# (FORMAT: STRING) 
+ERR_SYNTAX = Syntax error (try "--help")
+# an input or output support type description
+# (FORMAT: STRING) 
+ERR_SYNTAX_TYPE = > %s: %s
+# Error when retrieving data
+# (FORMAT: STRING) 
+ERR_LOADING = Error when retrieving data from: %s
+# Error when saving to given target
+# (FORMAT: STRING) 
+ERR_SAVING = Error when saving to target: %s
+# Error when unknown output format
+# (FORMAT: STRING) 
+ERR_BAD_OUTPUT_TYPE = Unknown output type: %s
+# Error when converting input to URL/File
+# (FORMAT: STRING) 
+ERR_BAD_URL = Cannot understand file or protocol: %s
+# URL/File not supported
+# (FORMAT: STRING) 
+ERR_NOT_SUPPORTED = URL not supported: %s
+# Failed to download cover : %s
+# (FORMAT: STRING) 
+ERR_BS_NO_COVER = Failed to download cover: %s
+# Canonical OPEN SINGLE QUOTE char (for instance: ‘)
+# (FORMAT: STRING) 
+OPEN_SINGLE_QUOTE = ‘
+# Canonical CLOSE SINGLE QUOTE char (for instance: ’)
+# (FORMAT: STRING) 
+CLOSE_SINGLE_QUOTE = ’
+# Canonical OPEN DOUBLE QUOTE char (for instance: “)
+# (FORMAT: STRING) 
+OPEN_DOUBLE_QUOTE = “
+# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
+# (FORMAT: STRING) 
+CLOSE_DOUBLE_QUOTE = ”
+# Name of the description fake chapter
+# (FORMAT: STRING) 
+DESCRIPTION = Description
+# Name of a chapter with a name
+# (FORMAT: STRING) 
+CHAPTER_NAMED = Chapter %d: %s
+# Name of a chapter without name
+# (FORMAT: STRING) 
+CHAPTER_UNNAMED = Chapter %d
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+INPUT_DESC = Unknown type: %s
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_EPUB = EPUB files created by this program (we do not support "all" EPUB files)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_TEXT = Stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
+\t\tline, possibly with the publication date in parenthesis\n\
+\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
+\t\tCHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, \n\
+\t\tJPEG or JPG extension.
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
+\tcompanion ".info" file to store some metadata
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FANFICTION = Fanfictions of many, many different universes, from TV shows to \n\
+\tnovels to games.
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FIMFICTION = Fanfictions devoted to the My Little Pony show
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_MANGAHUB = A well filled repository of mangas, in English
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E621 = Furry website supporting comics, including MLP
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E_HENTAI = Website offering many comics/mangas, mostly but not always NSFW
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_YIFFSTAR = A Furry website, story-oriented
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_CBZ = CBZ files coming from this very program
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_HTML = HTML files coming from this very program
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+OUTPUT_DESC = Unknown type: %s
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
+\t\tline, possibly with the publication date in parenthesis \n\
+\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
+\t\tCHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, JPEG \n\
+\t\tor JPG extension.
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
+\tcompanion ".info" file to store some metadata
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images \n\
+\tin PNG format by default)
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_HTML = HTML files (a directory containing the resources and "index.html")
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SYSOUT = A simple DEBUG console output
+# Default description when the type is not known by i18n
+# This item is used as a group, its content is not expected to be used.
+OUTPUT_DESC_SHORT = %s
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_EPUB = Electronic book (.epub)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_TEXT = Plain text (.txt)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_INFO_TEXT = Plain text and metadata
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_CBZ = Comic book (.cbz)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_LATEX = LaTeX (.tex)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_SYSOUT = Console output
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_HTML = HTML files with resources (directory, .html)
+# Error message for unknown 2-letter LaTeX language code
+# (FORMAT: STRING) 
+LATEX_LANG_UNKNOWN = Unknown language: %s
+# 'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection
+# (FORMAT: STRING) 
+BY = by
diff --git a/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_core_fr.properties
new file mode 100644 (file)
index 0000000..a64a5a0
--- /dev/null
@@ -0,0 +1,192 @@
+# français (fr) resources_core translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# help message for the syntax
+# (FORMAT: STRING) 
+HELP_SYNTAX = Options reconnues :\n\
+\t--import [URL]: importer une histoire dans la librairie\n\
+\t--export [id] [output_type] [target]: exporter l'histoire "id" vers le fichier donné\n\
+\t--convert [URL] [output_type] [target] (+info): convertir l'histoire vers le fichier donné, et forcer l'ajout d'un fichier .info si +info est utilisé\n\
+\t--read [id] ([chapter number]): afficher l'histoire "id"\n\
+\t--read-url [URL] ([chapter number]): convertir l'histoire et la lire à la volée, sans la sauver\n\
+\t--search: liste les sites supportés (where)\n\
+\t--search [where] [keywords] (page [page]) (item [item]): lance une recherche et \n\
+\t\taffiche les résultats de la page page (page 1 par défaut), et de l'item item \n\
+\t\tspécifique si demandé\n\
+\t--search-tag [where]: liste tous les tags supportés par ce site web\n\
+\t--search-tag [index 1]... (page [page]) (item [item]): affine la recherche, tag par tag,\n\
+\t\tet affiche si besoin les sous-tags, les histoires ou les infos précises de \n\
+\t\tl'histoire demandée\n\
+\t--list ([type]): lister les histoires presentes dans la librairie et leurs IDs\n\
+\t--set-source [id] [nouvelle source]: change la source de l'histoire\n\
+\t--set-title [id] [nouveau titre]: change le titre de l'histoire\n\
+\t--set-author [id] [nouvel auteur]: change l'auteur de l'histoire\n\
+\t--set-reader [reader type]: changer le type de lecteur pour la commande en cours sur CLI, TUI ou GUI\n\
+\t--server: démarre le mode serveur (les paramètres sont dans le fichier de config)\n\
+\t--stop-server: arrêter le serveur distant sur ce port (key doit avoir la même valeur) \n\
+\t--remote [key] [host] [port]: contacter ce server au lieu de la librairie habituelle (key doit avoir la même valeur)\n\
+\t--help: afficher la liste des options disponibles\n\
+\t--version: retourne la version du programme\n\
+\n\
+Types supportés en entrée :\n\
+%s\n\
+\n\
+Types supportés en sortie :\n\
+%s
+# syntax error message
+# (FORMAT: STRING) 
+ERR_SYNTAX = Erreur de syntaxe (essayez "--help")
+# an input or output support type description
+# (FORMAT: STRING) 
+ERR_SYNTAX_TYPE = > %s : %s
+# Error when retrieving data
+# (FORMAT: STRING) 
+ERR_LOADING = Erreur de récupération des données depuis : %s
+# Error when saving to given target
+# (FORMAT: STRING) 
+ERR_SAVING = Erreur lors de la sauvegarde sur : %s
+# Error when unknown output format
+# (FORMAT: STRING) 
+ERR_BAD_OUTPUT_TYPE = Type de sortie inconnu : %s
+# Error when converting input to URL/File
+# (FORMAT: STRING) 
+ERR_BAD_URL = Protocole ou type de fichier inconnu : %s
+# URL/File not supported
+# (FORMAT: STRING) 
+ERR_NOT_SUPPORTED = Site web non supporté : %s
+# Failed to download cover : %s
+# (FORMAT: STRING) 
+ERR_BS_NO_COVER = Échec de la récupération de la page de couverture : %s
+# Canonical OPEN SINGLE QUOTE char (for instance: ‘)
+# (FORMAT: STRING) 
+OPEN_SINGLE_QUOTE = ‘
+# Canonical CLOSE SINGLE QUOTE char (for instance: ’)
+# (FORMAT: STRING) 
+CLOSE_SINGLE_QUOTE = ’
+# Canonical OPEN DOUBLE QUOTE char (for instance: “)
+# (FORMAT: STRING) 
+OPEN_DOUBLE_QUOTE = “
+# Canonical CLOSE DOUBLE QUOTE char (for instance: ”)
+# (FORMAT: STRING) 
+CLOSE_DOUBLE_QUOTE = ”
+# Name of the description fake chapter
+# (FORMAT: STRING) 
+DESCRIPTION = Description
+# Name of a chapter with a name
+# (FORMAT: STRING) 
+CHAPTER_NAMED = Chapitre %d : %s
+# Name of a chapter without name
+# (FORMAT: STRING) 
+CHAPTER_UNNAMED = Chapitre %d
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+INPUT_DESC = Type d'entrée inconnu : %s
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_EPUB = Les fichiers .epub créés avec Fanfix (nous ne supportons pas les autres fichiers .epub, du moins pour le moment)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_TEXT = Les histoires enregistrées en texte (.txt), avec quelques règles spécifiques : \n\
+\tle titre doit être sur la première ligne\n\
+\tl'auteur (précédé de rien, "Par ", "De " ou "©") doit être sur la deuxième ligne, optionnellement suivi de la date de publication entre parenthèses (i.e., "Par Quelqu'un (3 octobre 1998)")\n\
+\tles chapitres doivent être déclarés avec "Chapitre x" ou "Chapitre x: NOM DU CHAPTITRE", où "x" est le numéro du chapitre\n\
+\tune description de l'histoire doit être donnée en tant que chaptire 0\n\
+\tune image de couverture peut être présente avec le même nom de fichier que l'histoire, mais une extension .png, .jpeg ou .jpg
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_INFO_TEXT = Fort proche du format texte, mais avec un fichier .info accompagnant l'histoire pour y enregistrer quelques metadata (le fichier de metadata est supposé être créé par Fanfix, ou être compatible avec)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FANFICTION = Fanfictions venant d'une multitude d'univers différents, depuis les shows télévisés aux livres en passant par les jeux-vidéos
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_FIMFICTION = Fanfictions dévouées à la série My Little Pony
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_MANGAHUB = Un site répertoriant une quantité non négligeable de mangas, en anglais
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E621 = Un site Furry proposant des comics, y compris de MLP
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_E_HENTAI = Un site web proposant beaucoup de comics/mangas, souvent mais pas toujours NSFW
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_YIFFSTAR = Un site web Furry, orienté sur les histoires plutôt que les images
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_CBZ = Les fichiers .cbz (une collection d'images zipées), de préférence créés avec Fanfix (même si les autres .cbz sont aussi supportés, mais sans la majorité des metadata de Fanfix dans ce cas)
+# Description of this input type
+# (FORMAT: STRING) 
+INPUT_DESC_HTML = Les fichiers HTML que vous pouvez ouvrir avec n'importe quel navigateur ; remarquez que Fanfix créera un répertoire pour y mettre les fichiers nécessaires, dont un fichier "index.html" pour afficher le tout -- nous ne supportons en entrée que les fichiers HTML créés par Fanfix
+# Default description when the type is not known by i18n
+# (FORMAT: STRING) 
+OUTPUT_DESC = Type de sortie inconnu : %s
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_EPUB = Standard EPUB file working on most e-book readers and viewers
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_TEXT = Local stories encoded in textual format, with a few rules :\n\
+\tthe title must be on the first line, \n\
+\tthe author (preceded by nothing, "by " or "©") must be on the second \n\
+\t\tline, possibly with the publication date in parenthesis \n\
+\t\t(i.e., "By Unknown (3rd October 1998)"), \n\
+\tchapters must be declared with "Chapter x" or "Chapter x: NAME OF THE \n\
+\t\tCHAPTER", where "x" is the chapter number,\n\
+\ta description of the story must be given as chapter number 0,\n\
+\ta cover image may be present with the same filename but a PNG, JPEG \n\
+\t\tor JPG extension.
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_INFO_TEXT = Contains the same information as the TEXT format, but with a \n\
+\tcompanion ".info" file to store some metadata
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_CBZ = CBZ file (basically a ZIP file containing images -- we store the images \n\
+\tin PNG format by default)
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_HTML = HTML files (a directory containing the resources and "index.html")
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_LATEX = A LaTeX file using the "book" template
+# Description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SYSOUT = A simple DEBUG console output
+# Default description when the type is not known by i18n
+# This item is used as a group, its content is not expected to be used.
+OUTPUT_DESC_SHORT = %s
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_EPUB = Electronic book (.epub)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_TEXT = Plain text (.txt)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_INFO_TEXT = Plain text and metadata
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_CBZ = Comic book (.cbz)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_LATEX = LaTeX (.tex)
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_SYSOUT = Console output
+# Short description of this output type
+# (FORMAT: STRING) 
+OUTPUT_DESC_SHORT_HTML = HTML files with resources (directory, .html)
+# Error message for unknown 2-letter LaTeX language code
+# (FORMAT: STRING) 
+LATEX_LANG_UNKNOWN = Unknown language: %s
+# 'by' prefix before author name used to output the author, make sure it is covered by Config.BYS for input detection
+# (FORMAT: STRING) 
+BY = by
diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui.properties b/src/be/nikiroo/fanfix/bundles/resources_gui.properties
new file mode 100644 (file)
index 0000000..40be5eb
--- /dev/null
@@ -0,0 +1,199 @@
+# United Kingdom (en_GB) resources_gui translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# the title of the main window of Fanfix, the library
+# (FORMAT: STRING) 
+TITLE_LIBRARY = Fanfix %s
+# the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)
+# (FORMAT: STRING) 
+TITLE_LIBRARY_WITH_NAME = Fanfix %s
+# the title of the configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG = Fanfix Configuration
+# the subtitle of the configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG = This is where you configure the options of the program.
+# the title of the UI configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG_UI = UI Configuration
+# the subtitle of the UI configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG_UI = This is where you configure the graphical appearence of the program.
+# the title of the 'save to/export to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_SAVE = Save
+# the title of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_MOVE_TO = Moving story
+# the subtitle of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_MOVE_TO = Move to:
+# the title of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_DELETE = Delete story
+# the subtitle of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_DELETE = Delete %s: %s
+# the title of the 'library error' dialogue
+# (FORMAT: STRING) 
+TITLE_ERROR_LIBRARY = Library error
+# the title of the 'import URL' dialogue
+# (FORMAT: STRING) 
+TITLE_IMPORT_URL = Importing from URL
+# the subtitle of the 'import URL' dialogue
+# (FORMAT: STRING) 
+SUBTITLE_IMPORT_URL = URL of the story to import:
+# the title of general error dialogues
+# (FORMAT: STRING) 
+TITLE_ERROR = Error
+# the title of a story for the properties dialogue, the viewers...
+# (FORMAT: STRING) 
+TITLE_STORY = %s: %s
+# HTML text used to notify of a new version
+# (FORMAT: STRING) 
+NEW_VERSION_AVAILABLE = A new version of the program is available at %s
+# text used as title for the update dialogue
+# (FORMAT: STRING) 
+NEW_VERSION_TITLE = Updates available
+# HTML text used to specify a newer version title and number, used for each version newer than the current one
+# (FORMAT: STRING) 
+NEW_VERSION_VERSION = Version %s
+# show the number of words of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_WORDS = %s words
+# show the number of images of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_IMAGES = %s images
+# show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)
+# (FORMAT: STRING) 
+BOOK_COUNT_STORIES = %s stories
+# the file menu
+# (FORMAT: STRING) 
+MENU_FILE = File
+# the file/exit menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXIT = Exit
+# the file/import_file menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_FILE = Import File...
+# the file/import_url menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_URL = Import URL...
+# the file/export menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXPORT = Save as...
+# the file/move to menu button
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO = Move to
+# the file/set author menu button
+# (FORMAT: STRING) 
+MENU_FILE_SET_AUTHOR = Set author
+# the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_TYPE = New source...
+# the file/move to/new author menu button, that will trigger a dialogue to create a new author
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_AUTHOR = New author...
+# the file/rename menu item, that will trigger a dialogue to ask for a new title for the story
+# (FORMAT: STRING) 
+MENU_FILE_RENAME = Rename...
+# the file/Properties menu item, that will trigger a dialogue to show the properties of the story
+# (FORMAT: STRING) 
+MENU_FILE_PROPERTIES = Properties
+# the file/open menu item, that will open the story or fake-story (an author or a source/type)
+# (FORMAT: STRING) 
+MENU_FILE_OPEN = Open
+# the edit menu
+# (FORMAT: STRING) 
+MENU_EDIT = Edit
+# the edit/send to cache menu button, to download the story into the cache if not already done
+# (FORMAT: STRING) 
+MENU_EDIT_DOWNLOAD_TO_CACHE = Prefetch to cache 
+# the clear cache menu button, to clear the cache for a single book
+# (FORMAT: STRING) 
+MENU_EDIT_CLEAR_CACHE = Clear cache
+# the edit/redownload menu button, to download the latest version of the book
+# (FORMAT: STRING) 
+MENU_EDIT_REDOWNLOAD = Redownload
+# the edit/delete menu button
+# (FORMAT: STRING) 
+MENU_EDIT_DELETE = Delete
+# the edit/Set as cover for source menu button
+# (FORMAT: STRING) 
+MENU_EDIT_SET_COVER_FOR_SOURCE = Set as cover for source
+# the edit/Set as cover for author menu button
+# (FORMAT: STRING) 
+MENU_EDIT_SET_COVER_FOR_AUTHOR = Set as cover for author
+# the search menu to open the earch stories on one of the searchable websites
+# (FORMAT: STRING) 
+MENU_SEARCH = Search
+# the view menu
+# (FORMAT: STRING) 
+MENU_VIEW = View
+# the view/word_count menu button, to show the word/image/story count as secondary info
+# (FORMAT: STRING) 
+MENU_VIEW_WCOUNT = Word count
+# the view/author menu button, to show the author as secondary info
+# (FORMAT: STRING) 
+MENU_VIEW_AUTHOR = Author
+# the sources menu, to select the books from a specific source; also used as a title for the source books
+# (FORMAT: STRING) 
+MENU_SOURCES = Sources
+# the authors menu, to select the books of a specific author; also used as a title for the author books
+# (FORMAT: STRING) 
+MENU_AUTHORS = Authors
+# the options menu, to configure Fanfix from the GUI
+# (FORMAT: STRING) 
+MENU_OPTIONS = Options
+# a special menu button to select all the sources/types or authors, by group (one book = one group)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_GROUPED = All
+# a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_LISTING = Listing
+# a special menu button to select the books without author
+# (FORMAT: STRING) 
+MENU_AUTHORS_UNKNOWN = [unknown]
+# progress bar caption for the 'reload books' step of all outOfUi operations
+# (FORMAT: STRING) 
+PROGRESS_OUT_OF_UI_RELOAD_BOOKS = Reload books
+# progress bar caption for the 'change source' step of the ReDownload operation
+# (FORMAT: STRING) 
+PROGRESS_CHANGE_SOURCE = Change the source of the book to %s
+# default description if the error is not known
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS = An error occured when contacting the library
+# library access not allowed
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_UNAUTHORIZED = You are not allowed to access this library
+# the library is invalid (not correctly set up)
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_INVALID = Library not valid
+# the library is out of commission
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_UNAVAILABLE = Library currently unavailable
+# cannot open the book, internal or external viewer
+# (FORMAT: STRING) 
+ERROR_CANNOT_OPEN = Cannot open the selected book
+# URL is not supported by Fanfix
+# (FORMAT: STRING) 
+ERROR_URL_NOT_SUPPORTED = URL not supported: %s
+# cannot import the URL
+# (FORMAT: STRING) 
+ERROR_URL_IMPORT_FAILED = Failed to import %s:\n\
+%s
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_UNNAMED = &nbsp;&nbsp;<B>Chapter <SPAN COLOR='#7777DD'>%d</SPAN>/%d</B>
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_NAMED = &nbsp;&nbsp;<B>Chapter <SPAN COLOR='#7777DD'>%d</SPAN>/%d</B>: %s
+# (NO html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+IMAGE_PROGRESSION = Image %d / %d
diff --git a/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties b/src/be/nikiroo/fanfix/bundles/resources_gui_fr.properties
new file mode 100644 (file)
index 0000000..25ff542
--- /dev/null
@@ -0,0 +1,199 @@
+# français (fr) resources_gui translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# the title of the main window of Fanfix, the library
+# (FORMAT: STRING) 
+TITLE_LIBRARY = Fanfix %s
+# the title of the main window of Fanfix, the library, when the library has a name (i.e., is not local)
+# (FORMAT: STRING) 
+TITLE_LIBRARY_WITH_NAME = Fanfix %s
+# the title of the configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG = Configuration de Fanfix
+# the subtitle of the configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG = C'est ici que vous pouvez configurer les options du programme.
+# the title of the UI configuration window of Fanfix, also the name of the menu button
+# (FORMAT: STRING) 
+TITLE_CONFIG_UI = Configuration de l'interface
+# the subtitle of the UI configuration window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_CONFIG_UI = C'est ici que vous pouvez configurer les options de l'apparence de l'application.
+# the title of the 'save to/export to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_SAVE = Sauver
+# the title of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_MOVE_TO = Déplacer le livre
+# the subtitle of the 'move to' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_MOVE_TO = Déplacer vers :
+# the title of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+TITLE_DELETE = Supprimer le livre
+# the subtitle of the 'delete' window of Fanfix
+# (FORMAT: STRING) 
+SUBTITLE_DELETE = Supprimer %s : %s
+# the title of the 'library error' dialogue
+# (FORMAT: STRING) 
+TITLE_ERROR_LIBRARY = Erreur avec la librairie
+# the title of the 'import URL' dialogue
+# (FORMAT: STRING) 
+TITLE_IMPORT_URL = Importer depuis une URL
+# the subtitle of the 'import URL' dialogue
+# (FORMAT: STRING) 
+SUBTITLE_IMPORT_URL = L'URL du livre à importer
+# the title of general error dialogues
+# (FORMAT: STRING) 
+TITLE_ERROR = Error
+# the title of a story for the properties dialogue, the viewers...
+# (FORMAT: STRING) 
+TITLE_STORY = %s: %s
+# HTML text used to notify of a new version
+# (FORMAT: STRING) 
+NEW_VERSION_AVAILABLE = Une nouvelle version du programme est disponible sur %s
+# text used as title for the update dialogue
+# (FORMAT: STRING) 
+NEW_VERSION_TITLE = Mise-à-jour disponible
+# HTML text used to specify a newer version title and number, used for each version newer than the current one
+# (FORMAT: STRING) 
+NEW_VERSION_VERSION = Version %s
+# show the number of words of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_WORDS = %s mots
+# show the number of images of a book
+# (FORMAT: STRING) 
+BOOK_COUNT_IMAGES = %s images
+# show the number of stories of a meta-book (a book representing allthe types/sources or all the authors present)
+# (FORMAT: STRING) 
+BOOK_COUNT_STORIES = %s livres
+# the file menu
+# (FORMAT: STRING) 
+MENU_FILE = Fichier
+# the file/exit menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXIT = Quiter
+# the file/import_file menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_FILE = Importer un fichier...
+# the file/import_url menu button
+# (FORMAT: STRING) 
+MENU_FILE_IMPORT_URL = Importer une URL...
+# the file/export menu button
+# (FORMAT: STRING) 
+MENU_FILE_EXPORT = Sauver sous...
+# the file/move to menu button
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO = Déplacer vers
+# the file/set author menu button
+# (FORMAT: STRING) 
+MENU_FILE_SET_AUTHOR = Changer l'auteur
+# the file/move to/new type-source menu button, that will trigger a dialogue to create a new type/source
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_TYPE = Nouvelle source...
+# the file/move to/new author menu button, that will trigger a dialogue to create a new author
+# (FORMAT: STRING) 
+MENU_FILE_MOVE_TO_NEW_AUTHOR = Nouvel auteur...
+# the file/rename menu item, that will trigger a dialogue to ask for a new title for the story
+# (FORMAT: STRING) 
+MENU_FILE_RENAME = Renommer...
+# the file/Properties menu item, that will trigger a dialogue to show the properties of the story
+# (FORMAT: STRING) 
+MENU_FILE_PROPERTIES = Propriétés
+# the file/open menu item, that will open the story or fake-story (an author or a source/type)
+# (FORMAT: STRING) 
+MENU_FILE_OPEN = Ouvrir
+# the edit menu
+# (FORMAT: STRING) 
+MENU_EDIT = Edition
+# the edit/send to cache menu button, to download the story into the cache if not already done
+# (FORMAT: STRING) 
+MENU_EDIT_DOWNLOAD_TO_CACHE = Précharger en cache 
+# the clear cache menu button, to clear the cache for a single book
+# (FORMAT: STRING) 
+MENU_EDIT_CLEAR_CACHE = Nettoyer le cache
+# the edit/redownload menu button, to download the latest version of the book
+# (FORMAT: STRING) 
+MENU_EDIT_REDOWNLOAD = Re-downloader
+# the edit/delete menu button
+# (FORMAT: STRING) 
+MENU_EDIT_DELETE = Supprimer
+# the edit/Set as cover for source menu button
+# (FORMAT: STRING) 
+MENU_EDIT_SET_COVER_FOR_SOURCE = Utiliser comme cover pour la source
+# the edit/Set as cover for author menu button
+# (FORMAT: STRING) 
+MENU_EDIT_SET_COVER_FOR_AUTHOR = Utiliser comme cover pour l'auteur
+# the search menu to open the earch stories on one of the searchable websites
+# (FORMAT: STRING) 
+MENU_SEARCH = Recherche
+# the view menu
+# (FORMAT: STRING) 
+MENU_VIEW = Affichage
+# the view/word_count menu button, to show the word/image/story count as secondary info
+# (FORMAT: STRING) 
+MENU_VIEW_WCOUNT = Nombre de mots
+# the view/author menu button, to show the author as secondary info
+# (FORMAT: STRING) 
+MENU_VIEW_AUTHOR = Auteur
+# the sources menu, to select the books from a specific source; also used as a title for the source books
+# (FORMAT: STRING) 
+MENU_SOURCES = Sources
+# the authors menu, to select the books of a specific author; also used as a title for the author books
+# (FORMAT: STRING) 
+MENU_AUTHORS = Auteurs
+# the options menu, to configure Fanfix from the GUI
+# (FORMAT: STRING) 
+MENU_OPTIONS = Options
+# a special menu button to select all the sources/types or authors, by group (one book = one group)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_GROUPED = Tout
+# a special menu button to select all the sources/types or authors, in a listing (all the included books are listed, grouped by source/type or author)
+# (FORMAT: STRING) 
+MENU_XXX_ALL_LISTING = Listing
+# a special menu button to select the books without author
+# (FORMAT: STRING) 
+MENU_AUTHORS_UNKNOWN = [inconnu]
+# progress bar caption for the 'reload books' step of all outOfUi operations
+# (FORMAT: STRING) 
+PROGRESS_OUT_OF_UI_RELOAD_BOOKS = Recharger les livres
+# progress bar caption for the 'change source' step of the ReDownload operation
+# (FORMAT: STRING) 
+PROGRESS_CHANGE_SOURCE = Change la source du livre en %s
+# default description if the error is not known
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS = Une erreur est survenue en contactant la librairie
+# library access not allowed
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_UNAUTHORIZED = Vous n'êtes pas autorisé à accéder à cette librairie
+# the library is invalid (not correctly set up)
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_INVALID = Librairie invalide
+# the library is out of commission
+# (FORMAT: STRING) 
+ERROR_LIB_STATUS_UNAVAILABLE = Librairie indisponible
+# cannot open the book, internal or external viewer
+# (FORMAT: STRING) 
+ERROR_CANNOT_OPEN = Impossible d'ouvrir le livre sélectionné
+# URL is not supported by Fanfix
+# (FORMAT: STRING) 
+ERROR_URL_NOT_SUPPORTED = URL non supportée : %s
+# cannot import the URL
+# (FORMAT: STRING) 
+ERROR_URL_IMPORT_FAILED = Erreur lors de l'import de %s:\n\
+%s
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_UNNAMED = &nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>
+# (html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+CHAPTER_HTML_NAMED = &nbsp;&nbsp;<B>Chapitre <SPAN COLOR='#444466'>%d</SPAN>&nbsp;/&nbsp;%d</B>: %s
+# (NO html) the chapter progression value used on the viewers
+# (FORMAT: STRING) 
+IMAGE_PROGRESSION = Image %d / %d
diff --git a/src/be/nikiroo/fanfix/bundles/ui_description.properties b/src/be/nikiroo/fanfix/bundles/ui_description.properties
new file mode 100644 (file)
index 0000000..c8def83
--- /dev/null
@@ -0,0 +1,35 @@
+# United Kingdom (en_GB) UI configuration options description translation file (UTF-8)
+# 
+# Note that any key can be doubled with a _NOUTF suffix
+# to use when the NOUTF env variable is set to 1
+# 
+# Also, the comments always refer to the key below them.
+# 
+
+
+# The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator
+# (FORMAT: DIRECTORY) absolute path, $HOME variable supported, / is always accepted as dir separator
+CACHE_DIR_LOCAL_READER = The directory where to store temporary files for the GUI reader; any relative path uses the applciation config directory as base, $HOME notation is supported, / is always accepted as directory separator
+# The type of output for the GUI Reader for non-images documents
+# (FORMAT: COMBO_LIST) One of the known output type
+# ALLOWED VALUES: "INFO_TEXT" "EPUB" "HTML" "TEXT"
+GUI_NON_IMAGES_DOCUMENT_TYPE = 
+# The type of output for the GUI Reader for images documents
+# (FORMAT: COMBO_LIST) 
+# ALLOWED VALUES: "CBZ" "HTML"
+GUI_IMAGES_DOCUMENT_TYPE = 
+# Use the internal reader for images documents -- this is TRUE by default
+# (FORMAT: BOOLEAN) 
+IMAGES_DOCUMENT_USE_INTERNAL_READER = 
+# The command launched for images documents -- default to the system default for the current file type
+# (FORMAT: STRING) A command to start
+IMAGES_DOCUMENT_READER = 
+# Use the internal reader for non images documents -- this is TRUE by default
+# (FORMAT: BOOLEAN) 
+NON_IMAGES_DOCUMENT_USE_INTERNAL_READER = 
+# The command launched for non images documents -- default to the system default for the current file type
+# (FORMAT: STRING) A command to start
+NON_IMAGES_DOCUMENT_READER = 
+# The background colour if you don't want the default system one
+# (FORMAT: COLOR) 
+BACKGROUND_COLOR = 
diff --git a/src/be/nikiroo/fanfix/data/Chapter.java b/src/be/nikiroo/fanfix/data/Chapter.java
new file mode 100644 (file)
index 0000000..d490058
--- /dev/null
@@ -0,0 +1,154 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * A chapter in the story (or the resume/description).
+ * 
+ * @author niki
+ */
+public class Chapter implements Iterable<Paragraph>, Cloneable, Serializable {
+       private static final long serialVersionUID = 1L;
+       
+       private String name;
+       private int number;
+       private List<Paragraph> paragraphs = new ArrayList<Paragraph>();
+       private List<Paragraph> empty = new ArrayList<Paragraph>();
+       private long words;
+
+       /**
+        * Empty constructor, not to use.
+        */
+       @SuppressWarnings("unused")
+       private Chapter() {
+               // for serialisation purposes
+       }
+
+       /**
+        * Create a new {@link Chapter} with the given information.
+        * 
+        * @param number
+        *            the chapter number, or 0 for the description/resume.
+        * @param name
+        *            the chapter name
+        */
+       public Chapter(int number, String name) {
+               this.number = number;
+               this.name = name;
+       }
+
+       /**
+        * The chapter name.
+        * 
+        * @return the name
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * The chapter name.
+        * 
+        * @param name
+        *            the name to set
+        */
+       public void setName(String name) {
+               this.name = name;
+       }
+
+       /**
+        * The chapter number, or 0 for the description/resume.
+        * 
+        * @return the number
+        */
+       public int getNumber() {
+               return number;
+       }
+
+       /**
+        * The chapter number, or 0 for the description/resume.
+        * 
+        * @param number
+        *            the number to set
+        */
+       public void setNumber(int number) {
+               this.number = number;
+       }
+
+       /**
+        * The included paragraphs.
+        * 
+        * @return the paragraphs
+        */
+       public List<Paragraph> getParagraphs() {
+               return paragraphs;
+       }
+
+       /**
+        * The included paragraphs.
+        * 
+        * @param paragraphs
+        *            the paragraphs to set
+        */
+       public void setParagraphs(List<Paragraph> paragraphs) {
+               this.paragraphs = paragraphs;
+       }
+
+       /**
+        * Get an iterator on the {@link Paragraph}s.
+        */
+       @Override
+       public Iterator<Paragraph> iterator() {
+               return paragraphs == null ? empty.iterator() : paragraphs.iterator();
+       }
+
+       /**
+        * The number of words (or images) in this {@link Chapter}.
+        * 
+        * @return the number of words
+        */
+       public long getWords() {
+               return words;
+       }
+
+       /**
+        * The number of words (or images) in this {@link Chapter}.
+        * 
+        * @param words
+        *            the number of words to set
+        */
+       public void setWords(long words) {
+               this.words = words;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               return "Chapter " + number + ": " + name;
+       }
+
+       @Override
+       public Chapter clone() {
+               Chapter chap = null;
+               try {
+                       chap = (Chapter) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               if (paragraphs != null) {
+                       chap.paragraphs = new ArrayList<Paragraph>();
+                       for (Paragraph para : paragraphs) {
+                               chap.paragraphs.add(para.clone());
+                       }
+               }
+
+               return chap;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/JsonIO.java b/src/be/nikiroo/fanfix/data/JsonIO.java
new file mode 100644 (file)
index 0000000..fee60ce
--- /dev/null
@@ -0,0 +1,288 @@
+package be.nikiroo.fanfix.data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+public class JsonIO {
+       static public JSONObject toJson(MetaData meta) {
+               if (meta == null) {
+                       return null;
+               }
+
+               JSONObject json = new JSONObject();
+               put(json, "", MetaData.class.getName());
+               put(json, "luid", meta.getLuid());
+               put(json, "title", meta.getTitle());
+               put(json, "author", meta.getAuthor());
+               put(json, "source", meta.getSource());
+               put(json, "url", meta.getUrl());
+               put(json, "words", meta.getWords());
+               put(json, "creation_date", meta.getCreationDate());
+               put(json, "date", meta.getDate());
+               put(json, "lang", meta.getLang());
+               put(json, "publisher", meta.getPublisher());
+               put(json, "subject", meta.getSubject());
+               put(json, "type", meta.getType());
+               put(json, "uuid", meta.getUuid());
+               put(json, "resume", toJson(meta.getResume()));
+               put(json, "tags", new JSONArray(meta.getTags()));
+
+               return json;
+       }
+
+       /**
+        * // no image
+        * 
+        * @param json
+        * 
+        * @return
+        * 
+        * @throws JSONException
+        *             when it cannot be converted
+        */
+       static public MetaData toMetaData(JSONObject json) {
+               if (json == null) {
+                       return null;
+               }
+
+               MetaData meta = new MetaData();
+               meta.setLuid(getString(json, "luid"));
+               meta.setTitle(getString(json, "title"));
+               meta.setAuthor(getString(json, "author"));
+               meta.setSource(getString(json, "source"));
+               meta.setUrl(getString(json, "url"));
+               meta.setWords(getLong(json, "words", 0));
+               meta.setCreationDate(getString(json, "creation_date"));
+               meta.setDate(getString(json, "date"));
+               meta.setLang(getString(json, "lang"));
+               meta.setPublisher(getString(json, "publisher"));
+               meta.setSubject(getString(json, "subject"));
+               meta.setType(getString(json, "type"));
+               meta.setUuid(getString(json, "uuid"));
+
+               meta.setResume(toChapter(getJson(json, "resume")));
+               meta.setTags(toListString(getJsonArr(json, "tags")));
+
+               return meta;
+       }
+
+       static public JSONObject toJson(Story story) {
+               if (story == null) {
+                       return null;
+               }
+
+               JSONObject json = new JSONObject();
+               put(json, "", Story.class.getName());
+               put(json, "meta", toJson(story.getMeta()));
+
+               List<JSONObject> chapters = new ArrayList<JSONObject>();
+               for (Chapter chap : story) {
+                       chapters.add(toJson(chap));
+               }
+               put(json, "chapters", new JSONArray(chapters));
+
+               return json;
+       }
+
+       /**
+        * 
+        * @param json
+        * 
+        * @return
+        * 
+        * @throws JSONException
+        *             when it cannot be converted
+        */
+       static public Story toStory(JSONObject json) {
+               if (json == null) {
+                       return null;
+               }
+
+               Story story = new Story();
+               story.setMeta(toMetaData(getJson(json,"meta")));
+               story.setChapters(toListChapter(getJsonArr(json, "chapters")));
+
+               return story;
+       }
+
+       static public JSONObject toJson(Chapter chap) {
+               if (chap == null) {
+                       return null;
+               }
+
+               JSONObject json = new JSONObject();
+               put(json, "", Chapter.class.getName());
+               put(json, "name", chap.getName());
+               put(json, "number", chap.getNumber());
+               put(json, "words", chap.getWords());
+
+               List<JSONObject> paragraphs = new ArrayList<JSONObject>();
+               for (Paragraph para : chap) {
+                       paragraphs.add(toJson(para));
+               }
+               put(json, "paragraphs", new JSONArray(paragraphs));
+
+               return json;
+       }
+
+       /**
+        * 
+        * @param json
+        * 
+        * @return
+        * 
+        * @throws JSONException
+        *             when it cannot be converted
+        */
+       static public Chapter toChapter(JSONObject json) {
+               if (json == null) {
+                       return null;
+               }
+
+               Chapter chap = new Chapter(getInt(json, "number", 0),
+                               getString(json, "name"));
+               chap.setWords(getLong(json, "words", 0));
+               chap.setParagraphs(toListParagraph(getJsonArr(json, "paragraphs")));
+
+               return chap;
+       }
+
+       // no images
+       static public JSONObject toJson(Paragraph para) {
+               if (para == null) {
+                       return null;
+               }
+
+               JSONObject json = new JSONObject();
+               put(json, "", Paragraph.class.getName());
+               put(json, "type", para.getType());
+               put(json, "content", para.getContent());
+               put(json, "words", para.getWords());
+
+               return json;
+       }
+
+       /**
+        * // no images
+        * 
+        * @param json
+        * 
+        * @return
+        * 
+        * @throws JSONException
+        *             when it cannot be converted
+        */
+       static public Paragraph toParagraph(JSONObject json) {
+               if (json == null) {
+                       return null;
+               }
+
+               Paragraph para = new Paragraph(
+                               ParagraphType.valueOf(getString(json, "type")),
+                               getString(json, "content"), getLong(json, "words", 0));
+
+               return para;
+       }
+
+       static public List<String> toListString(JSONArray array) {
+               if (array != null) {
+                       List<String> values = new ArrayList<String>();
+                       for (Object value : array.toList()) {
+                               values.add(value == null ? null : value.toString());
+                       }
+                       return values;
+               }
+
+               return null;
+       }
+
+       static public List<Paragraph> toListParagraph(JSONArray array) {
+               if (array != null) {
+                       List<Paragraph> values = new ArrayList<Paragraph>();
+                       for (Object value : array.toList()) {
+                               values.add(
+                                               value instanceof Paragraph ? (Paragraph) value : null);
+                       }
+                       return values;
+               }
+
+               return null;
+       }
+
+       private static List<Chapter> toListChapter(JSONArray array) {
+               if (array != null) {
+                       List<Chapter> values = new ArrayList<Chapter>();
+                       for (Object value : array.toList()) {
+                               values.add(value instanceof Chapter ? (Chapter) value : null);
+                       }
+                       return values;
+               }
+
+               return null;
+       }
+
+       static private void put(JSONObject json, String key, Object o) {
+               json.put(key, o == null ? JSONObject.NULL : o);
+       }
+
+       static String getString(JSONObject json, String key) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof String) {
+                               return (String) o;
+                       }
+               }
+
+               return null;
+       }
+
+       static long getLong(JSONObject json, String key, long def) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof Long) {
+                               return (Long) o;
+                       }
+               }
+
+               return def;
+       }
+
+       static int getInt(JSONObject json, String key, int def) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof Integer) {
+                               return (Integer) o;
+                       }
+               }
+
+               return def;
+       }
+
+       static JSONObject getJson(JSONObject json, String key) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof JSONObject) {
+                               return (JSONObject) o;
+                       }
+               }
+
+               return null;
+       }
+
+       static JSONArray getJsonArr(JSONObject json, String key) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof JSONArray) {
+                               return (JSONArray) o;
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/MetaData.java b/src/be/nikiroo/fanfix/data/MetaData.java
new file mode 100644 (file)
index 0000000..586196a
--- /dev/null
@@ -0,0 +1,486 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * The meta data associated to a {@link Story} object.
+ * 
+ * @author niki
+ */
+public class MetaData implements Cloneable, Comparable<MetaData>, Serializable {
+       private static final long serialVersionUID = 1L;
+
+       private String title;
+       private String author;
+       private String date;
+       private Chapter resume;
+       private List<String> tags;
+       private Image cover;
+       private String subject;
+       private String source;
+       private String url;
+       private String uuid;
+       private String luid;
+       private String lang;
+       private String publisher;
+       private String type;
+       private boolean imageDocument;
+       private long words;
+       private String creationDate;
+       private boolean fakeCover;
+
+       /**
+        * The title of the story.
+        * 
+        * @return the title
+        */
+       public String getTitle() {
+               return title;
+       }
+
+       /**
+        * The title of the story.
+        * 
+        * @param title
+        *            the title to set
+        */
+       public void setTitle(String title) {
+               this.title = title;
+       }
+
+       /**
+        * The author of the story.
+        * 
+        * @return the author
+        */
+       public String getAuthor() {
+               return author;
+       }
+
+       /**
+        * The author of the story.
+        * 
+        * @param author
+        *            the author to set
+        */
+       public void setAuthor(String author) {
+               this.author = author;
+       }
+
+       /**
+        * The story publication date.
+        * 
+        * @return the date
+        */
+       public String getDate() {
+               return date;
+       }
+
+       /**
+        * The story publication date.
+        * 
+        * @param date
+        *            the date to set
+        */
+       public void setDate(String date) {
+               this.date = date;
+       }
+
+       /**
+        * The tags associated with this story.
+        * 
+        * @return the tags
+        */
+       public List<String> getTags() {
+               return tags;
+       }
+
+       /**
+        * The tags associated with this story.
+        * 
+        * @param tags
+        *            the tags to set
+        */
+       public void setTags(List<String> tags) {
+               this.tags = tags;
+       }
+
+       /**
+        * The story resume (a.k.a. description).
+        * <p>
+        * This can be NULL if we don't have a resume for this {@link Story}.
+        * 
+        * @return the resume
+        */
+       public Chapter getResume() {
+               return resume;
+       }
+
+       /**
+        * The story resume (a.k.a. description).
+        * 
+        * @param resume
+        *            the resume to set
+        */
+       public void setResume(Chapter resume) {
+               this.resume = resume;
+       }
+
+       /**
+        * The cover image of the story if any (can be NULL).
+        * 
+        * @return the cover
+        */
+       public Image getCover() {
+               return cover;
+       }
+
+       /**
+        * The cover image of the story if any (can be NULL).
+        * 
+        * @param cover
+        *            the cover to set
+        */
+       public void setCover(Image cover) {
+               this.cover = cover;
+       }
+
+       /**
+        * The subject of the story (or instance, if it is a fanfiction, what is the
+        * original work; if it is a technical text, what is the technical
+        * subject...).
+        * 
+        * @return the subject
+        */
+       public String getSubject() {
+               return subject;
+       }
+
+       /**
+        * The subject of the story (for instance, if it is a fanfiction, what is
+        * the original work; if it is a technical text, what is the technical
+        * subject...).
+        * 
+        * @param subject
+        *            the subject to set
+        */
+       public void setSubject(String subject) {
+               this.subject = subject;
+       }
+
+       /**
+        * The source of this story (which online library it was downloaded from).
+        * 
+        * @return the source
+        */
+       public String getSource() {
+               return source;
+       }
+
+       /**
+        * The source of this story (which online library it was downloaded from).
+        * 
+        * @param source
+        *            the source to set
+        */
+       public void setSource(String source) {
+               this.source = source;
+       }
+
+       /**
+        * The original URL from which this {@link Story} was imported.
+        * 
+        * @return the url
+        */
+       public String getUrl() {
+               return url;
+       }
+
+       /**
+        * The original URL from which this {@link Story} was imported.
+        * 
+        * @param url
+        *            the new url to set
+        */
+       public void setUrl(String url) {
+               this.url = url;
+       }
+
+       /**
+        * A unique value representing the story (it is often a URL).
+        * 
+        * @return the uuid
+        */
+       public String getUuid() {
+               return uuid;
+       }
+
+       /**
+        * A unique value representing the story (it is often a URL).
+        * 
+        * @param uuid
+        *            the uuid to set
+        */
+       public void setUuid(String uuid) {
+               this.uuid = uuid;
+       }
+
+       /**
+        * A unique value representing the story in the local library.
+        * 
+        * @return the luid
+        */
+       public String getLuid() {
+               return luid;
+       }
+
+       /**
+        * A unique value representing the story in the local library.
+        * 
+        * @param luid
+        *            the luid to set
+        */
+       public void setLuid(String luid) {
+               this.luid = luid;
+       }
+
+       /**
+        * The 2-letter code language of this story.
+        * 
+        * @return the lang
+        */
+       public String getLang() {
+               return lang;
+       }
+
+       /**
+        * The 2-letter code language of this story.
+        * 
+        * @param lang
+        *            the lang to set
+        */
+       public void setLang(String lang) {
+               this.lang = lang;
+       }
+
+       /**
+        * The story publisher (other the same as the source).
+        * 
+        * @return the publisher
+        */
+       public String getPublisher() {
+               return publisher;
+       }
+
+       /**
+        * The story publisher (other the same as the source).
+        * 
+        * @param publisher
+        *            the publisher to set
+        */
+       public void setPublisher(String publisher) {
+               this.publisher = publisher;
+       }
+
+       /**
+        * The output type this {@link Story} is in.
+        * 
+        * @return the type the type
+        */
+       public String getType() {
+               return type;
+       }
+
+       /**
+        * The output type this {@link Story} is in.
+        * 
+        * @param type
+        *            the new type to set
+        */
+       public void setType(String type) {
+               this.type = type;
+       }
+
+       /**
+        * Document catering mostly to image files.
+        * 
+        * @return the imageDocument state
+        */
+       public boolean isImageDocument() {
+               return imageDocument;
+       }
+
+       /**
+        * Document catering mostly to image files.
+        * 
+        * @param imageDocument
+        *            the imageDocument state to set
+        */
+       public void setImageDocument(boolean imageDocument) {
+               this.imageDocument = imageDocument;
+       }
+
+       /**
+        * The number of words in the related {@link Story}.
+        * 
+        * @return the number of words
+        */
+       public long getWords() {
+               return words;
+       }
+
+       /**
+        * The number of words in the related {@link Story}.
+        * 
+        * @param words
+        *            the number of words to set
+        */
+       public void setWords(long words) {
+               this.words = words;
+       }
+
+       /**
+        * The (Fanfix) {@link Story} creation date.
+        * 
+        * @return the creationDate
+        */
+       public String getCreationDate() {
+               return creationDate;
+       }
+
+       /**
+        * The (Fanfix) {@link Story} creation date.
+        * 
+        * @param creationDate
+        *            the creationDate to set
+        */
+       public void setCreationDate(String creationDate) {
+               this.creationDate = creationDate;
+       }
+
+       /**
+        * The cover in this {@link MetaData} object is "fake", in the sens that it
+        * comes from the actual content images.
+        * 
+        * @return TRUE for a fake cover
+        */
+       public boolean isFakeCover() {
+               return fakeCover;
+       }
+
+       /**
+        * The cover in this {@link MetaData} object is "fake", in the sens that it
+        * comes from the actual content images
+        * 
+        * @param fakeCover
+        *            TRUE for a fake cover
+        */
+       public void setFakeCover(boolean fakeCover) {
+               this.fakeCover = fakeCover;
+       }
+
+       @Override
+       public int compareTo(MetaData o) {
+               if (o == null) {
+                       return 1;
+               }
+
+               String id = (getTitle() == null ? "" : getTitle())
+                               + (getUuid() == null ? "" : getUuid())
+                               + (getLuid() == null ? "" : getLuid());
+               String oId = (getTitle() == null ? "" : o.getTitle())
+                               + (getUuid() == null ? "" : o.getUuid())
+                               + (o.getLuid() == null ? "" : o.getLuid());
+
+               return id.compareToIgnoreCase(oId);
+       }
+
+       @Override
+       public boolean equals(Object obj) {
+               if (!(obj instanceof MetaData)) {
+                       return false;
+               }
+
+               return compareTo((MetaData) obj) == 0;
+       }
+
+       @Override
+       public int hashCode() {
+               String uuid = getUuid();
+               if (uuid == null) {
+                       uuid = "" + title + author + source;
+               }
+
+               return uuid.hashCode();
+       }
+
+       @Override
+       public MetaData clone() {
+               MetaData meta = null;
+               try {
+                       meta = (MetaData) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               if (tags != null) {
+                       meta.tags = new ArrayList<String>(tags);
+               }
+
+               if (resume != null) {
+                       meta.resume = resume.clone();
+               }
+
+               return meta;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        * <p>
+        * This is not efficient, nor intended to be.
+        */
+       @Override
+       public String toString() {
+               String title = "";
+               if (getTitle() != null) {
+                       title = getTitle();
+               }
+
+               StringBuilder tags = new StringBuilder();
+               if (getTags() != null) {
+                       for (String tag : getTags()) {
+                               if (tags.length() > 0) {
+                                       tags.append(", ");
+                               }
+                               tags.append(tag);
+                       }
+               }
+
+               String resume = "";
+               if (getResume() != null) {
+                       for (Paragraph para : getResume()) {
+                               resume += "\n\t";
+                               resume += para.toString().substring(0,
+                                               Math.min(para.toString().length(), 120));
+                       }
+                       resume += "\n";
+               }
+
+               String cover = "none";
+               if (getCover() != null) {
+                       cover = StringUtils.formatNumber(getCover().getSize())
+                                       + "bytes";
+               }
+
+               return String.format(
+                               "Meta %s:\n\tTitle: [%s]\n\tAuthor: [%s]\n\tDate: [%s]\n\tTags: [%s]"
+                                               + "\n\tResume: [%s]\n\tCover: [%s]", luid, title,
+                               getAuthor(), getDate(), tags.toString(), resume, cover);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/Paragraph.java b/src/be/nikiroo/fanfix/data/Paragraph.java
new file mode 100644 (file)
index 0000000..d5a0f1c
--- /dev/null
@@ -0,0 +1,182 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+
+import be.nikiroo.utils.Image;
+
+/**
+ * A paragraph in a chapter of the story.
+ * 
+ * @author niki
+ */
+public class Paragraph implements Cloneable, Serializable {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * A paragraph type, that will dictate how the paragraph will be handled.
+        * 
+        * @author niki
+        */
+       public enum ParagraphType {
+               /** Normal paragraph (text) */
+               NORMAL,
+               /** Blank line */
+               BLANK,
+               /** A Break paragraph, i.e.: HR (Horizontal Line) or '* * *' or whatever */
+               BREAK,
+               /** Quotation (dialogue) */
+               QUOTE,
+               /** An image (no text) */
+               IMAGE, ;
+
+               /**
+                * This paragraph type is of a text kind (quote or not).
+                * 
+                * @param allowEmpty
+                *            allow empty text as text, too (blanks, breaks...)
+                * @return TRUE if it is
+                */
+               public boolean isText(boolean allowEmpty) {
+                       return (this == NORMAL || this == QUOTE)
+                                       || (allowEmpty && (this == BLANK || this == BREAK));
+               }
+       }
+
+       private ParagraphType type;
+       private String content;
+       private Image contentImage;
+       private long words;
+
+       /**
+        * Empty constructor, not to use.
+        */
+       @SuppressWarnings("unused")
+       private Paragraph() {
+               // for serialisation purposes
+       }
+
+       /**
+        * Create a new {@link Paragraph} with the given image.
+        * 
+        * @param contentImage
+        *            the image
+        */
+       public Paragraph(Image contentImage) {
+               this(ParagraphType.IMAGE, null, 1);
+               this.contentImage = contentImage;
+       }
+
+       /**
+        * Create a new {@link Paragraph} with the given values.
+        * 
+        * @param type
+        *            the {@link ParagraphType}
+        * @param content
+        *            the content of this paragraph
+        * @param words
+        *            the number of words (or images)
+        */
+       public Paragraph(ParagraphType type, String content, long words) {
+               this.type = type;
+               this.content = content;
+               this.words = words;
+       }
+
+       /**
+        * The {@link ParagraphType}.
+        * 
+        * @return the type
+        */
+       public ParagraphType getType() {
+               return type;
+       }
+
+       /**
+        * The {@link ParagraphType}.
+        * 
+        * @param type
+        *            the type to set
+        */
+       public void setType(ParagraphType type) {
+               this.type = type;
+       }
+
+       /**
+        * The content of this {@link Paragraph} if it is not an image.
+        * 
+        * @return the content
+        */
+       public String getContent() {
+               return content;
+       }
+
+       /**
+        * The content of this {@link Paragraph}.
+        * 
+        * @param content
+        *            the content to set
+        */
+       public void setContent(String content) {
+               this.content = content;
+       }
+
+       /**
+        * The content of this {@link Paragraph} if it is an image.
+        * 
+        * @return the content
+        */
+       public Image getContentImage() {
+               return contentImage;
+       }
+
+       /**
+        * The content of this {@link Paragraph} if it is an image.
+        * 
+        * @param contentImage
+        *            the content
+        */
+       public void setContentImage(Image contentImage) {
+               this.contentImage = contentImage;
+       }
+       
+       /**
+        * The number of words (or images) in this {@link Paragraph}.
+        * 
+        * @return the number of words
+        */
+       public long getWords() {
+               return words;
+       }
+
+       /**
+        * The number of words (or images) in this {@link Paragraph}.
+        * 
+        * @param words
+        *            the number of words to set
+        */
+       public void setWords(long words) {
+               this.words = words;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               return String.format("%s: [%s]", "" + type, content == null ? "N/A"
+                               : content);
+       }
+
+       @Override
+       public Paragraph clone() {
+               Paragraph para = null;
+               try {
+                       para = (Paragraph) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               return para;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/Story.java b/src/be/nikiroo/fanfix/data/Story.java
new file mode 100644 (file)
index 0000000..fc3f909
--- /dev/null
@@ -0,0 +1,101 @@
+package be.nikiroo.fanfix.data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * The main data class, where the whole story resides.
+ * 
+ * @author niki
+ */
+public class Story implements Iterable<Chapter>, Cloneable, Serializable {
+       private static final long serialVersionUID = 1L;
+       
+       private MetaData meta;
+       private List<Chapter> chapters = new ArrayList<Chapter>();
+       private List<Chapter> empty = new ArrayList<Chapter>();
+
+       /**
+        * The metadata about this {@link Story}.
+        * 
+        * @return the meta
+        */
+       public MetaData getMeta() {
+               return meta;
+       }
+
+       /**
+        * The metadata about this {@link Story}.
+        * 
+        * @param meta
+        *            the meta to set
+        */
+       public void setMeta(MetaData meta) {
+               this.meta = meta;
+       }
+
+       /**
+        * The chapters of the story.
+        * 
+        * @return the chapters
+        */
+       public List<Chapter> getChapters() {
+               return chapters;
+       }
+
+       /**
+        * The chapters of the story.
+        * 
+        * @param chapters
+        *            the chapters to set
+        */
+       public void setChapters(List<Chapter> chapters) {
+               this.chapters = chapters;
+       }
+
+       /**
+        * Get an iterator on the {@link Chapter}s.
+        */
+       @Override
+       public Iterator<Chapter> iterator() {
+               return chapters == null ? empty.iterator() : chapters.iterator();
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        * <p>
+        * This is not efficient, nor intended to be.
+        */
+       @Override
+       public String toString() {
+               if (getMeta() != null)
+                       return "Story: [\n" + getMeta().toString() + "\n]";
+               return "Story: [ no metadata found ]";
+       }
+
+       @Override
+       public Story clone() {
+               Story story = null;
+               try {
+                       story = (Story) super.clone();
+               } catch (CloneNotSupportedException e) {
+                       // Did the clones rebel?
+                       System.err.println(e);
+               }
+
+               if (meta != null) {
+                       story.meta = meta.clone();
+               }
+
+               if (chapters != null) {
+                       story.chapters = new ArrayList<Chapter>();
+                       for (Chapter chap : chapters) {
+                               story.chapters.add(chap.clone());
+                       }
+               }
+
+               return story;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/data/package-info.java b/src/be/nikiroo/fanfix/data/package-info.java
new file mode 100644 (file)
index 0000000..57db36b
--- /dev/null
@@ -0,0 +1,9 @@
+/**
+ * This package contains the data structure used by the program, without the 
+ * logic behind them.
+ * <p>
+ * All the classes inside are serializable.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.data;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/BasicLibrary.java b/src/be/nikiroo/fanfix/library/BasicLibrary.java
new file mode 100644 (file)
index 0000000..d435f8d
--- /dev/null
@@ -0,0 +1,904 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Manage a library of Stories: import, export, list, modify.
+ * <p>
+ * Each {@link Story} object will be associated with a (local to the library)
+ * unique ID, the LUID, which will be used to identify the {@link Story}.
+ * <p>
+ * Most of the {@link BasicLibrary} functions work on a partial (cover
+ * <b>MAY</b> not be included) {@link MetaData} object.
+ * 
+ * @author niki
+ */
+abstract public class BasicLibrary {
+       /**
+        * A {@link BasicLibrary} status.
+        * 
+        * @author niki
+        */
+       public enum Status {
+               /** The library is ready and r/w. */
+               READ_WRITE,
+               /** The library is ready, but read-only. */
+               READ_ONLY,
+               /** The library is invalid (not correctly set up). */
+               INVALID,
+               /** You are not allowed to access this library. */
+               UNAUTHORIZED,
+               /** The library is currently out of commission. */
+               UNAVAILABLE;
+
+               /**
+                * The library is available (you can query it).
+                * <p>
+                * It does <b>not</b> specify if it is read-only or not.
+                * 
+                * @return TRUE if it is
+                */
+               public boolean isReady() {
+                       return (this == READ_WRITE || this == READ_ONLY);
+               }
+
+               /**
+                * This library can be modified (= you are allowed to modify it).
+                * 
+                * @return TRUE if it is
+                */
+               public boolean isWritable() {
+                       return (this == READ_WRITE);
+               }
+       }
+
+       /**
+        * Return a name for this library (the UI may display this).
+        * <p>
+        * Must not be NULL.
+        * 
+        * @return the name, or an empty {@link String} if none
+        */
+       public String getLibraryName() {
+               return "";
+       }
+
+       /**
+        * The library status.
+        * 
+        * @return the current status
+        */
+       public Status getStatus() {
+               return Status.READ_WRITE;
+       }
+
+       /**
+        * Retrieve the main {@link File} corresponding to the given {@link Story},
+        * which can be passed to an external reader or instance.
+        * <p>
+        * Do <b>NOT</b> alter this file.
+        * 
+        * @param luid
+        *            the Library UID of the story, can be NULL
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the corresponding {@link Story}
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract File getFile(String luid, Progress pg) throws IOException;
+
+       /**
+        * Return the cover image associated to this story.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * 
+        * @return the cover image
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract Image getCover(String luid) throws IOException;
+
+       // TODO: ensure it is the main used interface
+       public MetaResultList getList(Progress pg) throws IOException {
+               return new MetaResultList(getMetas(pg));
+       }
+
+       // TODO: make something for (normal and custom) not-story covers
+
+       /**
+        * Return the cover image associated to this source.
+        * <p>
+        * By default, return the custom cover if any, and if not, return the cover
+        * of the first story with this source.
+        * 
+        * @param source
+        *            the source
+        * 
+        * @return the cover image or NULL
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Image getSourceCover(String source) throws IOException {
+               Image custom = getCustomSourceCover(source);
+               if (custom != null) {
+                       return custom;
+               }
+
+               List<MetaData> metas = getList().filter(source, null, null);
+               if (metas.size() > 0) {
+                       return getCover(metas.get(0).getLuid());
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the cover image associated to this author.
+        * <p>
+        * By default, return the custom cover if any, and if not, return the cover
+        * of the first story with this author.
+        * 
+        * @param author
+        *            the author
+        * 
+        * @return the cover image or NULL
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Image getAuthorCover(String author) throws IOException {
+               Image custom = getCustomAuthorCover(author);
+               if (custom != null) {
+                       return custom;
+               }
+
+               List<MetaData> metas = getList().filter(null, author, null);
+               if (metas.size() > 0) {
+                       return getCover(metas.get(0).getLuid());
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the custom cover image associated to this source.
+        * <p>
+        * By default, return NULL.
+        * 
+        * @param source
+        *            the source to look for
+        * 
+        * @return the custom cover or NULL if none
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       @SuppressWarnings("unused")
+       public Image getCustomSourceCover(String source) throws IOException {
+               return null;
+       }
+
+       /**
+        * Return the custom cover image associated to this author.
+        * <p>
+        * By default, return NULL.
+        * 
+        * @param author
+        *            the author to look for
+        * 
+        * @return the custom cover or NULL if none
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       @SuppressWarnings("unused")
+       public Image getCustomAuthorCover(String author) throws IOException {
+               return null;
+       }
+
+       /**
+        * Set the source cover to the given story cover.
+        * 
+        * @param source
+        *            the source to change
+        * @param luid
+        *            the story LUID
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract void setSourceCover(String source, String luid)
+                       throws IOException;
+
+       /**
+        * Set the author cover to the given story cover.
+        * 
+        * @param author
+        *            the author to change
+        * @param luid
+        *            the story LUID
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public abstract void setAuthorCover(String author, String luid)
+                       throws IOException;
+
+       /**
+        * Return the list of stories (represented by their {@link MetaData}, which
+        * <b>MAY</b> not have the cover included).
+        * <p>
+        * The returned list <b>MUST</b> be a copy, not the original one.
+        * 
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the list (can be empty but not NULL)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       protected abstract List<MetaData> getMetas(Progress pg) throws IOException;
+
+       /**
+        * Invalidate the {@link Story} cache (when the content should be re-read
+        * because it was changed).
+        */
+       protected void invalidateInfo() {
+               invalidateInfo(null);
+       }
+
+       /**
+        * Invalidate the {@link Story} cache (when the content is removed).
+        * <p>
+        * All the cache can be deleted if NULL is passed as meta.
+        * 
+        * @param luid
+        *            the LUID of the {@link Story} to clear from the cache, or NULL
+        *            for all stories
+        */
+       protected abstract void invalidateInfo(String luid);
+
+       /**
+        * Invalidate the {@link Story} cache (when the content has changed, but we
+        * already have it) with the new given meta.
+        * 
+        * @param meta
+        *            the {@link Story} to clear from the cache
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       protected abstract void updateInfo(MetaData meta) throws IOException;
+
+       /**
+        * Return the next LUID that can be used.
+        * 
+        * @return the next luid
+        */
+       protected abstract int getNextId();
+
+       /**
+        * Delete the target {@link Story}.
+        * 
+        * @param luid
+        *            the LUID of the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} wa not found
+        */
+       protected abstract void doDelete(String luid) throws IOException;
+
+       /**
+        * Actually save the story to the back-end.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the saved {@link Story} (which may have changed, especially
+        *         regarding the {@link MetaData})
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract Story doSave(Story story, Progress pg)
+                       throws IOException;
+
+       /**
+        * Refresh the {@link BasicLibrary}, that is, make sure all metas are
+        * loaded.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        */
+       public void refresh(Progress pg) {
+               try {
+                       getMetas(pg);
+               } catch (IOException e) {
+                       // We will let it fail later
+               }
+       }
+
+       /**
+        * Check if the {@link Story} denoted by this Library UID is present in the
+        * cache (if we have no cache, we default to </tt>true</tt>).
+        * 
+        * @param luid
+        *            the Library UID
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isCached(@SuppressWarnings("unused") String luid) {
+               // By default, everything is cached
+               return true;
+       }
+
+       /**
+        * Clear the {@link Story} from the cache, if needed.
+        * <p>
+        * The next time we try to retrieve the {@link Story}, it may be required to
+        * cache it again.
+        * 
+        * @param luid
+        *            the story to clear
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       @SuppressWarnings("unused")
+       public void clearFromCache(String luid) throws IOException {
+               // By default, this is a noop.
+       }
+
+       /**
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getSources()} instead.
+        */
+       @Deprecated
+       public List<String> getSources() throws IOException {
+               return getList().getSources();
+       }
+
+       /**
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getSourcesGrouped()} instead.
+        */
+       @Deprecated
+       public Map<String, List<String>> getSourcesGrouped() throws IOException {
+               return getList().getSourcesGrouped();
+       }
+
+       /**
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getAuthors()} instead.
+        */
+       @Deprecated
+       public List<String> getAuthors() throws IOException {
+               return getList().getAuthors();
+       }
+
+       /**
+        * @deprecated please use {@link BasicLibrary#getList()} and
+        *             {@link MetaResultList#getAuthorsGrouped()} instead.
+        */
+       public Map<String, List<String>> getAuthorsGrouped() throws IOException {
+               return getList().getAuthorsGrouped();
+       }
+
+       /**
+        * List all the stories in the {@link BasicLibrary}.
+        * <p>
+        * Cover images <b>MAYBE</b> not included.
+        * 
+        * @return the stories
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public MetaResultList getList() throws IOException {
+               return getList(null);
+       }
+
+       /**
+        * Retrieve a {@link MetaData} corresponding to the given {@link Story},
+        * cover image <b>MAY</b> not be included.
+        * 
+        * @param luid
+        *            the Library UID of the story, can be NULL
+        * 
+        * @return the corresponding {@link Story} or NULL if not found
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public MetaData getInfo(String luid) throws IOException {
+               if (luid != null) {
+                       for (MetaData meta : getMetas(null)) {
+                               if (luid.equals(meta.getLuid())) {
+                                       return meta;
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Retrieve a specific {@link Story}.
+        * 
+        * @param luid
+        *            the Library UID of the story
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the corresponding {@link Story} or NULL if not found
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Story getStory(String luid, Progress pg) throws IOException {
+               Progress pgMetas = new Progress();
+               Progress pgStory = new Progress();
+               if (pg != null) {
+                       pg.setMinMax(0, 100);
+                       pg.addProgress(pgMetas, 10);
+                       pg.addProgress(pgStory, 90);
+               }
+
+               MetaData meta = null;
+               for (MetaData oneMeta : getMetas(pgMetas)) {
+                       if (oneMeta.getLuid().equals(luid)) {
+                               meta = oneMeta;
+                               break;
+                       }
+               }
+
+               pgMetas.done();
+
+               Story story = getStory(luid, meta, pgStory);
+               pgStory.done();
+
+               return story;
+       }
+
+       /**
+        * Retrieve a specific {@link Story}.
+        * 
+        * @param luid
+        *            the LUID of the story
+        * @param meta
+        *            the meta of the story
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the corresponding {@link Story} or NULL if not found
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+                       throws IOException {
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgGet = new Progress();
+               Progress pgProcess = new Progress();
+
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgGet, 1);
+               pg.addProgress(pgProcess, 1);
+
+               Story story = null;
+               File file = null;
+
+               if (luid != null && meta != null) {
+                       file = getFile(luid, pgGet);
+               }
+
+               pgGet.done();
+               try {
+                       if (file != null) {
+                               SupportType type = SupportType.valueOfAllOkUC(meta.getType());
+                               if (type == null) {
+                                       throw new IOException("Unknown type: " + meta.getType());
+                               }
+
+                               URL url = file.toURI().toURL();
+                               story = BasicSupport.getSupport(type, url) //
+                                               .process(pgProcess);
+
+                               // Because we do not want to clear the meta cache:
+                               meta.setCover(story.getMeta().getCover());
+                               meta.setResume(story.getMeta().getResume());
+                               story.setMeta(meta);
+                       }
+               } catch (IOException e) {
+                       // We should not have not-supported files in the library
+                       Instance.getInstance().getTraceHandler()
+                                       .error(new IOException(String.format(
+                                                       "Cannot load file of type '%s' from library: %s",
+                                                       meta.getType(), file), e));
+               } finally {
+                       pgProcess.done();
+                       pg.done();
+               }
+
+               return story;
+       }
+
+       /**
+        * Import the {@link Story} at the given {@link URL} into the
+        * {@link BasicLibrary}.
+        * 
+        * @param url
+        *            the {@link URL} to import
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the imported Story {@link MetaData}
+        * 
+        * @throws UnknownHostException
+        *             if the host is not supported
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public MetaData imprt(URL url, Progress pg) throws IOException {
+               if (pg == null)
+                       pg = new Progress();
+
+               pg.setMinMax(0, 1000);
+               Progress pgProcess = new Progress();
+               Progress pgSave = new Progress();
+               pg.addProgress(pgProcess, 800);
+               pg.addProgress(pgSave, 200);
+
+               BasicSupport support = BasicSupport.getSupport(url);
+               if (support == null) {
+                       throw new UnknownHostException("" + url);
+               }
+
+               Story story = save(support.process(pgProcess), pgSave);
+               pg.done();
+
+               return story.getMeta();
+       }
+
+       /**
+        * Import the story from one library to another, and keep the same LUID.
+        * 
+        * @param other
+        *            the other library to import from
+        * @param luid
+        *            the Library UID
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void imprt(BasicLibrary other, String luid, Progress pg)
+                       throws IOException {
+               Progress pgGetStory = new Progress();
+               Progress pgSave = new Progress();
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgGetStory, 1);
+               pg.addProgress(pgSave, 1);
+
+               Story story = other.getStory(luid, pgGetStory);
+               if (story != null) {
+                       story = this.save(story, luid, pgSave);
+                       pg.done();
+               } else {
+                       pg.done();
+                       throw new IOException("Cannot find story in Library: " + luid);
+               }
+       }
+
+       /**
+        * Export the {@link Story} to the given target in the given format.
+        * 
+        * @param luid
+        *            the {@link Story} ID
+        * @param type
+        *            the {@link OutputType} to transform it to
+        * @param target
+        *            the target to save to
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the saved resource (the main saved {@link File})
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File export(String luid, OutputType type, String target, Progress pg)
+                       throws IOException {
+               Progress pgGetStory = new Progress();
+               Progress pgOut = new Progress();
+               if (pg != null) {
+                       pg.setMax(2);
+                       pg.addProgress(pgGetStory, 1);
+                       pg.addProgress(pgOut, 1);
+               }
+
+               BasicOutput out = BasicOutput.getOutput(type, false, false);
+               if (out == null) {
+                       throw new IOException("Output type not supported: " + type);
+               }
+
+               Story story = getStory(luid, pgGetStory);
+               if (story == null) {
+                       throw new IOException("Cannot find story to export: " + luid);
+               }
+
+               return out.process(story, target, pgOut);
+       }
+
+       /**
+        * Save a {@link Story} to the {@link BasicLibrary}.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the same {@link Story}, whose LUID may have changed
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story save(Story story, Progress pg) throws IOException {
+               return save(story, null, pg);
+       }
+
+       /**
+        * Save a {@link Story} to the {@link BasicLibrary} -- the LUID <b>must</b>
+        * be correct, or NULL to get the next free one.
+        * <p>
+        * Will override any previous {@link Story} with the same LUID.
+        * 
+        * @param story
+        *            the {@link Story} to save
+        * @param luid
+        *            the <b>correct</b> LUID or NULL to get the next free one
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the same {@link Story}, whose LUID may have changed
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public synchronized Story save(Story story, String luid, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Instance.getInstance().getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": saving story " + luid);
+
+               // Do not change the original metadata, but change the original story
+               MetaData meta = story.getMeta().clone();
+               story.setMeta(meta);
+
+               pg.setName("Saving story");
+
+               if (luid == null || luid.isEmpty()) {
+                       meta.setLuid(String.format("%03d", getNextId()));
+               } else {
+                       meta.setLuid(luid);
+               }
+
+               if (luid != null && getInfo(luid) != null) {
+                       delete(luid);
+               }
+
+               story = doSave(story, pg);
+
+               updateInfo(story.getMeta());
+
+               Instance.getInstance().getTraceHandler()
+                               .trace(this.getClass().getSimpleName() + ": story saved ("
+                                               + luid + ")");
+
+               pg.setName(meta.getTitle());
+               pg.done();
+               return story;
+       }
+
+       /**
+        * Delete the given {@link Story} from this {@link BasicLibrary}.
+        * 
+        * @param luid
+        *            the LUID of the target {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public synchronized void delete(String luid) throws IOException {
+               Instance.getInstance().getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": deleting story " + luid);
+
+               doDelete(luid);
+               invalidateInfo(luid);
+
+               Instance.getInstance().getTraceHandler()
+                               .trace(this.getClass().getSimpleName() + ": story deleted ("
+                                               + luid + ")");
+       }
+
+       /**
+        * Change the type (source) of the given {@link Story}.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newSource
+        *            the new source
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       public synchronized void changeSource(String luid, String newSource,
+                       Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               changeSTA(luid, newSource, meta.getTitle(), meta.getAuthor(), pg);
+       }
+
+       /**
+        * Change the title (name) of the given {@link Story}.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newTitle
+        *            the new title
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       public synchronized void changeTitle(String luid, String newTitle,
+                       Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               changeSTA(luid, meta.getSource(), newTitle, meta.getAuthor(), pg);
+       }
+
+       /**
+        * Change the author of the given {@link Story}.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newAuthor
+        *            the new author
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       public synchronized void changeAuthor(String luid, String newAuthor,
+                       Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               changeSTA(luid, meta.getSource(), meta.getTitle(), newAuthor, pg);
+       }
+
+       /**
+        * Change the Source, Title and Author of the {@link Story} in one single
+        * go.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * @param newSource
+        *            the new source
+        * @param newTitle
+        *            the new title
+        * @param newAuthor
+        *            the new author
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       protected synchronized void changeSTA(String luid, String newSource,
+                       String newTitle, String newAuthor, Progress pg) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               meta.setSource(newSource);
+               meta.setTitle(newTitle);
+               meta.setAuthor(newAuthor);
+               saveMeta(meta, pg);
+       }
+
+       /**
+        * Save back the current state of the {@link MetaData} (LUID <b>MUST NOT</b>
+        * change) for this {@link Story}.
+        * <p>
+        * By default, delete the old {@link Story} then recreate a new
+        * {@link Story}.
+        * <p>
+        * Note that this behaviour can lead to data loss in case of problems!
+        * 
+        * @param meta
+        *            the new {@link MetaData} (LUID <b>MUST NOT</b> change)
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @throws IOException
+        *             in case of I/O error or if the {@link Story} was not found
+        */
+       protected synchronized void saveMeta(MetaData meta, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgGet = new Progress();
+               Progress pgSet = new Progress();
+               pg.addProgress(pgGet, 50);
+               pg.addProgress(pgSet, 50);
+
+               Story story = getStory(meta.getLuid(), pgGet);
+               if (story == null) {
+                       throw new IOException("Story not found: " + meta.getLuid());
+               }
+
+               // TODO: this is not safe!
+               delete(meta.getLuid());
+               story.setMeta(meta);
+               save(story, meta.getLuid(), pgSet);
+
+               pg.done();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/CacheLibrary.java b/src/be/nikiroo/fanfix/library/CacheLibrary.java
new file mode 100644 (file)
index 0000000..a3c3b5e
--- /dev/null
@@ -0,0 +1,435 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeSet;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.bundles.UiConfigBundle;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * This library will cache another pre-existing {@link BasicLibrary}.
+ * 
+ * @author niki
+ */
+public class CacheLibrary extends BasicLibrary {
+       private List<MetaData> metasReal;
+       private List<MetaData> metasMixed;
+       private Object metasLock = new Object();
+
+       private BasicLibrary lib;
+       private LocalLibrary cacheLib;
+
+       /**
+        * Create a cache library around the given one.
+        * <p>
+        * It will return the same result, but those will be saved to disk at the
+        * same time to be fetched quicker the next time.
+        * 
+        * @param cacheDir
+        *            the cache directory where to save the files to disk
+        * @param lib
+        *            the original library to wrap
+        * @param config
+        *            the configuration used to know which kind of default
+        *            {@link OutputType} to use for images and non-images stories
+        */
+       public CacheLibrary(File cacheDir, BasicLibrary lib,
+                       UiConfigBundle config) {
+               this.cacheLib = new LocalLibrary(cacheDir, //
+                               config.getString(UiConfig.GUI_NON_IMAGES_DOCUMENT_TYPE),
+                               config.getString(UiConfig.GUI_IMAGES_DOCUMENT_TYPE), true);
+               this.lib = lib;
+       }
+
+       @Override
+       public String getLibraryName() {
+               return lib.getLibraryName();
+       }
+
+       @Override
+       public Status getStatus() {
+               return lib.getStatus();
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               List<MetaData> copy;
+               synchronized (metasLock) {
+                       // We make sure that cached metas have precedence
+                       if (metasMixed == null) {
+                               if (metasReal == null) {
+                                       metasReal = lib.getMetas(pg);
+                               }
+
+                               metasMixed = new ArrayList<MetaData>();
+                               TreeSet<String> cachedLuids = new TreeSet<String>();
+                               for (MetaData cachedMeta : cacheLib.getMetas(null)) {
+                                       metasMixed.add(cachedMeta);
+                                       cachedLuids.add(cachedMeta.getLuid());
+                               }
+                               for (MetaData realMeta : metasReal) {
+                                       if (!cachedLuids.contains(realMeta.getLuid())) {
+                                               metasMixed.add(realMeta);
+                                       }
+                               }
+                       }
+
+                       copy = new ArrayList<MetaData>(metasMixed);
+               }
+
+               pg.done();
+               return copy;
+       }
+
+       @Override
+       public synchronized Story getStory(String luid, MetaData meta, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgImport = new Progress();
+               Progress pgGet = new Progress();
+
+               pg.setMinMax(0, 4);
+               pg.addProgress(pgImport, 3);
+               pg.addProgress(pgGet, 1);
+
+               if (!isCached(luid)) {
+                       try {
+                               cacheLib.imprt(lib, luid, pgImport);
+                               updateMetaCache(metasMixed, cacheLib.getInfo(luid));
+                               pgImport.done();
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+
+                       pgImport.done();
+                       pgGet.done();
+               }
+
+               String type = cacheLib.getOutputType(meta.isImageDocument());
+               MetaData cachedMeta = meta.clone();
+               cachedMeta.setType(type);
+
+               return cacheLib.getStory(luid, cachedMeta, pg);
+       }
+
+       @Override
+       public synchronized File getFile(final String luid, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgGet = new Progress();
+               Progress pgRecall = new Progress();
+
+               pg.setMinMax(0, 5);
+               pg.addProgress(pgGet, 4);
+               pg.addProgress(pgRecall, 1);
+
+               if (!isCached(luid)) {
+                       getStory(luid, pgGet);
+                       pgGet.done();
+               }
+
+               File file = cacheLib.getFile(luid, pgRecall);
+               pgRecall.done();
+
+               pg.done();
+               return file;
+       }
+
+       @Override
+       public Image getCover(final String luid) throws IOException {
+               if (isCached(luid)) {
+                       return cacheLib.getCover(luid);
+               }
+
+               // We could update the cache here, but it's not easy
+               return lib.getCover(luid);
+       }
+
+       @Override
+       public Image getSourceCover(String source) throws IOException {
+               Image custom = getCustomSourceCover(source);
+               if (custom != null) {
+                       return custom;
+               }
+
+               Image cached = cacheLib.getSourceCover(source);
+               if (cached != null) {
+                       return cached;
+               }
+
+               return lib.getSourceCover(source);
+       }
+
+       @Override
+       public Image getAuthorCover(String author) throws IOException {
+               Image custom = getCustomAuthorCover(author);
+               if (custom != null) {
+                       return custom;
+               }
+
+               Image cached = cacheLib.getAuthorCover(author);
+               if (cached != null) {
+                       return cached;
+               }
+
+               return lib.getAuthorCover(author);
+       }
+
+       @Override
+       public Image getCustomSourceCover(String source) throws IOException {
+               Image custom = cacheLib.getCustomSourceCover(source);
+               if (custom == null) {
+                       custom = lib.getCustomSourceCover(source);
+                       if (custom != null) {
+                               cacheLib.setSourceCover(source, custom);
+                       }
+               }
+
+               return custom;
+       }
+
+       @Override
+       public Image getCustomAuthorCover(String author) throws IOException {
+               Image custom = cacheLib.getCustomAuthorCover(author);
+               if (custom == null) {
+                       custom = lib.getCustomAuthorCover(author);
+                       if (custom != null) {
+                               cacheLib.setAuthorCover(author, custom);
+                       }
+               }
+
+               return custom;
+       }
+
+       @Override
+       public void setSourceCover(String source, String luid) throws IOException {
+               lib.setSourceCover(source, luid);
+               cacheLib.setSourceCover(source, getCover(luid));
+       }
+
+       @Override
+       public void setAuthorCover(String author, String luid) throws IOException {
+               lib.setAuthorCover(author, luid);
+               cacheLib.setAuthorCover(author, getCover(luid));
+       }
+
+       /**
+        * Invalidate the {@link Story} cache (when the content has changed, but we
+        * already have it) with the new given meta.
+        * <p>
+        * <b>Make sure to always use {@link MetaData} from the cached library in
+        * priority, here.</b>
+        * 
+        * @param meta
+        *            the {@link Story} to clear from the cache
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       @Override
+       @Deprecated
+       protected void updateInfo(MetaData meta) throws IOException {
+               throw new IOException(
+                               "This method is not supported in a CacheLibrary, please use updateMetaCache");
+       }
+
+       // relplace the meta in Metas by Meta, add it if needed
+       // return TRUE = added
+       private boolean updateMetaCache(List<MetaData> metas, MetaData meta) {
+               if (meta != null && metas != null) {
+                       synchronized (metasLock) {
+                               boolean changed = false;
+                               for (int i = 0; i < metas.size(); i++) {
+                                       if (metas.get(i).getLuid().equals(meta.getLuid())) {
+                                               metas.set(i, meta);
+                                               changed = true;
+                                       }
+                               }
+
+                               if (!changed) {
+                                       metas.add(meta);
+                                       return true;
+                               }
+                       }
+               }
+
+               return false;
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               if (luid == null) {
+                       synchronized (metasLock) {
+                               metasReal = null;
+                               metasMixed = null;
+                       }
+               } else {
+                       invalidateInfo(metasReal, luid);
+                       invalidateInfo(metasMixed, luid);
+               }
+
+               cacheLib.invalidateInfo(luid);
+               lib.invalidateInfo(luid);
+       }
+
+       // luid cannot be null
+       private void invalidateInfo(List<MetaData> metas, String luid) {
+               if (metas != null) {
+                       synchronized (metasLock) {
+                               for (int i = 0; i < metas.size(); i++) {
+                                       if (metas.get(i).getLuid().equals(luid)) {
+                                               metas.remove(i--);
+                                       }
+                               }
+                       }
+               }
+       }
+
+       @Override
+       public synchronized Story save(Story story, String luid, Progress pg)
+                       throws IOException {
+               Progress pgLib = new Progress();
+               Progress pgCacheLib = new Progress();
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgLib, 1);
+               pg.addProgress(pgCacheLib, 1);
+
+               story = lib.save(story, luid, pgLib);
+               updateMetaCache(metasReal, story.getMeta());
+
+               story = cacheLib.save(story, story.getMeta().getLuid(), pgCacheLib);
+               updateMetaCache(metasMixed, story.getMeta());
+
+               return story;
+       }
+
+       @Override
+       public synchronized void delete(String luid) throws IOException {
+               if (isCached(luid)) {
+                       cacheLib.delete(luid);
+               }
+               lib.delete(luid);
+
+               invalidateInfo(luid);
+       }
+
+       @Override
+       protected synchronized void changeSTA(String luid, String newSource,
+                       String newTitle, String newAuthor, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgCache = new Progress();
+               Progress pgOrig = new Progress();
+               pg.setMinMax(0, 2);
+               pg.addProgress(pgCache, 1);
+               pg.addProgress(pgOrig, 1);
+
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               if (isCached(luid)) {
+                       cacheLib.changeSTA(luid, newSource, newTitle, newAuthor, pgCache);
+               }
+               pgCache.done();
+
+               lib.changeSTA(luid, newSource, newTitle, newAuthor, pgOrig);
+               pgOrig.done();
+
+               meta.setSource(newSource);
+               meta.setTitle(newTitle);
+               meta.setAuthor(newAuthor);
+               pg.done();
+
+               if (isCached(luid)) {
+                       updateMetaCache(metasMixed, meta);
+                       updateMetaCache(metasReal, lib.getInfo(luid));
+               } else {
+                       updateMetaCache(metasReal, meta);
+               }
+       }
+
+       @Override
+       public boolean isCached(String luid) {
+               try {
+                       return cacheLib.getInfo(luid) != null;
+               } catch (IOException e) {
+                       return false;
+               }
+       }
+
+       @Override
+       public void clearFromCache(String luid) throws IOException {
+               if (isCached(luid)) {
+                       cacheLib.delete(luid);
+               }
+       }
+
+       @Override
+       public MetaData imprt(URL url, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               Progress pgImprt = new Progress();
+               Progress pgCache = new Progress();
+               pg.setMinMax(0, 10);
+               pg.addProgress(pgImprt, 7);
+               pg.addProgress(pgCache, 3);
+
+               MetaData meta = lib.imprt(url, pgImprt);
+               updateMetaCache(metasReal, meta);
+               metasMixed = null;
+
+               clearFromCache(meta.getLuid());
+
+               pg.done();
+               return meta;
+       }
+
+       // All the following methods are only used by Save and Delete in
+       // BasicLibrary:
+
+       @Override
+       protected int getNextId() {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/LocalLibrary.java b/src/be/nikiroo/fanfix/library/LocalLibrary.java
new file mode 100644 (file)
index 0000000..6720972
--- /dev/null
@@ -0,0 +1,754 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.fanfix.output.InfoCover;
+import be.nikiroo.fanfix.supported.InfoReader;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This {@link BasicLibrary} will store the stories locally on disk.
+ * 
+ * @author niki
+ */
+public class LocalLibrary extends BasicLibrary {
+       private int lastId;
+       private Object lock = new Object();
+       private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
+       private Map<String, Image> sourceCovers;
+       private Map<String, Image> authorCovers;
+
+       private File baseDir;
+       private OutputType text;
+       private OutputType image;
+
+       /**
+        * Create a new {@link LocalLibrary} with the given back-end directory.
+        * 
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        * @param config
+        *            the configuration used to know which kind of default
+        *            {@link OutputType} to use for images and non-images stories
+        */
+       public LocalLibrary(File baseDir, ConfigBundle config) {
+               this(baseDir, //
+                               config.getString(Config.FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE),
+                               config.getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE),
+                               false);
+       }
+
+       /**
+        * Create a new {@link LocalLibrary} with the given back-end directory.
+        * 
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        * @param text
+        *            the {@link OutputType} to use for non-image documents
+        * @param image
+        *            the {@link OutputType} to use for image documents
+        * @param defaultIsHtml
+        *            if the given text or image is invalid, use HTML by default (if
+        *            not, it will be INFO_TEXT/CBZ by default)
+        */
+       public LocalLibrary(File baseDir, String text, String image,
+                       boolean defaultIsHtml) {
+               this(baseDir,
+                               OutputType.valueOfAllOkUC(text,
+                                               defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
+                               OutputType.valueOfAllOkUC(image,
+                                               defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
+       }
+
+       /**
+        * Create a new {@link LocalLibrary} with the given back-end directory.
+        * 
+        * @param baseDir
+        *            the directory where to find the {@link Story} objects
+        * @param text
+        *            the {@link OutputType} to use for non-image documents
+        * @param image
+        *            the {@link OutputType} to use for image documents
+        */
+       public LocalLibrary(File baseDir, OutputType text, OutputType image) {
+               this.baseDir = baseDir;
+               this.text = text;
+               this.image = image;
+
+               this.lastId = 0;
+               this.stories = null;
+               this.sourceCovers = null;
+
+               baseDir.mkdirs();
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) {
+               return new ArrayList<MetaData>(getStories(pg).keySet());
+       }
+
+       @Override
+       public File getFile(String luid, Progress pg) throws IOException {
+               Instance.getInstance().getTraceHandler().trace(
+                               this.getClass().getSimpleName() + ": get file for " + luid);
+
+               File file = null;
+               String mess = "no file found for ";
+
+               MetaData meta = getInfo(luid);
+               if (meta != null) {
+                       File[] files = getStories(pg).get(meta);
+                       if (files != null) {
+                               mess = "file retrieved for ";
+                               file = files[1];
+                       }
+               }
+
+               Instance.getInstance().getTraceHandler()
+                               .trace(this.getClass().getSimpleName() + ": " + mess + luid
+                                               + " (" + meta.getTitle() + ")");
+
+               return file;
+       }
+
+       @Override
+       public Image getCover(String luid) throws IOException {
+               MetaData meta = getInfo(luid);
+               if (meta != null) {
+                       if (meta.getCover() != null) {
+                               return meta.getCover();
+                       }
+
+                       File[] files = getStories(null).get(meta);
+                       if (files != null) {
+                               File infoFile = files[0];
+
+                               try {
+                                       meta = InfoReader.readMeta(infoFile, true);
+                                       return meta.getCover();
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected void updateInfo(MetaData meta) {
+               invalidateInfo();
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               synchronized (lock) {
+                       stories = null;
+                       sourceCovers = null;
+               }
+       }
+
+       @Override
+       protected int getNextId() {
+               getStories(null); // make sure lastId is set
+
+               synchronized (lock) {
+                       return ++lastId;
+               }
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               for (File file : getRelatedFiles(luid)) {
+                       // TODO: throw an IOException if we cannot delete the files?
+                       IOUtils.deltree(file);
+                       file.getParentFile().delete();
+               }
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               MetaData meta = story.getMeta();
+
+               File expectedTarget = getExpectedFile(meta);
+               expectedTarget.getParentFile().mkdirs();
+
+               BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
+               it.process(story, expectedTarget.getPath(), pg);
+
+               return story;
+       }
+
+       @Override
+       protected synchronized void saveMeta(MetaData meta, Progress pg)
+                       throws IOException {
+               File newDir = getExpectedDir(meta.getSource());
+               if (!newDir.exists()) {
+                       newDir.mkdirs();
+               }
+
+               List<File> relatedFiles = getRelatedFiles(meta.getLuid());
+               for (File relatedFile : relatedFiles) {
+                       // TODO: this is not safe at all.
+                       // We should copy all the files THEN delete them
+                       // Maybe also adding some rollback cleanup if possible
+                       if (relatedFile.getName().endsWith(".info")) {
+                               try {
+                                       String name = relatedFile.getName().replaceFirst("\\.info$",
+                                                       "");
+                                       relatedFile.delete();
+                                       InfoCover.writeInfo(newDir, name, meta);
+                                       relatedFile.getParentFile().delete();
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                               }
+                       } else {
+                               relatedFile.renameTo(new File(newDir, relatedFile.getName()));
+                               relatedFile.getParentFile().delete();
+                       }
+               }
+
+               updateInfo(meta);
+       }
+
+       @Override
+       public Image getCustomSourceCover(String source) {
+               synchronized (lock) {
+                       if (sourceCovers == null) {
+                               sourceCovers = new HashMap<String, Image>();
+                       }
+               }
+
+               synchronized (lock) {
+                       Image img = sourceCovers.get(source);
+                       if (img != null) {
+                               return img;
+                       }
+               }
+
+               File coverDir = getExpectedDir(source);
+               if (coverDir.isDirectory()) {
+                       File cover = new File(coverDir, ".cover.png");
+                       if (cover.exists()) {
+                               InputStream in;
+                               try {
+                                       in = new FileInputStream(cover);
+                                       try {
+                                               synchronized (lock) {
+                                                       sourceCovers.put(source, new Image(in));
+                                               }
+                                       } finally {
+                                               in.close();
+                                       }
+                               } catch (FileNotFoundException e) {
+                                       e.printStackTrace();
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .error(new IOException(
+                                                                       "Cannot load the existing custom source cover: "
+                                                                                       + cover,
+                                                                       e));
+                               }
+                       }
+               }
+
+               synchronized (lock) {
+                       return sourceCovers.get(source);
+               }
+       }
+
+       @Override
+       public Image getCustomAuthorCover(String author) {
+               synchronized (lock) {
+                       if (authorCovers == null) {
+                               authorCovers = new HashMap<String, Image>();
+                       }
+               }
+
+               synchronized (lock) {
+                       Image img = authorCovers.get(author);
+                       if (img != null) {
+                               return img;
+                       }
+               }
+
+               File cover = getAuthorCoverFile(author);
+               if (cover.exists()) {
+                       InputStream in;
+                       try {
+                               in = new FileInputStream(cover);
+                               try {
+                                       synchronized (lock) {
+                                               authorCovers.put(author, new Image(in));
+                                       }
+                               } finally {
+                                       in.close();
+                               }
+                       } catch (FileNotFoundException e) {
+                               e.printStackTrace();
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler()
+                                               .error(new IOException(
+                                                               "Cannot load the existing custom author cover: "
+                                                                               + cover,
+                                                               e));
+                       }
+               }
+
+               synchronized (lock) {
+                       return authorCovers.get(author);
+               }
+       }
+
+       @Override
+       public void setSourceCover(String source, String luid) throws IOException {
+               setSourceCover(source, getCover(luid));
+       }
+
+       @Override
+       public void setAuthorCover(String author, String luid) throws IOException {
+               setAuthorCover(author, getCover(luid));
+       }
+
+       /**
+        * Set the source cover to the given story cover.
+        * 
+        * @param source
+        *            the source to change
+        * @param coverImage
+        *            the cover image
+        */
+       void setSourceCover(String source, Image coverImage) {
+               File dir = getExpectedDir(source);
+               dir.mkdirs();
+               File cover = new File(dir, ".cover");
+               try {
+                       Instance.getInstance().getCache().saveAsImage(coverImage, cover,
+                                       true);
+                       synchronized (lock) {
+                               if (sourceCovers != null) {
+                                       sourceCovers.put(source, coverImage);
+                               }
+                       }
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Set the author cover to the given story cover.
+        * 
+        * @param author
+        *            the author to change
+        * @param coverImage
+        *            the cover image
+        */
+       void setAuthorCover(String author, Image coverImage) {
+               File cover = getAuthorCoverFile(author);
+               cover.getParentFile().mkdirs();
+               try {
+                       Instance.getInstance().getCache().saveAsImage(coverImage, cover,
+                                       true);
+                       synchronized (lock) {
+                               if (authorCovers != null) {
+                                       authorCovers.put(author, coverImage);
+                               }
+                       }
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+       }
+
+       @Override
+       public void imprt(BasicLibrary other, String luid, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               // Check if we can simply copy the files instead of the whole process
+               if (other instanceof LocalLibrary) {
+                       LocalLibrary otherLocalLibrary = (LocalLibrary) other;
+
+                       MetaData meta = otherLocalLibrary.getInfo(luid);
+                       String expectedType = ""
+                                       + (meta != null && meta.isImageDocument() ? image : text);
+                       if (meta != null && meta.getType().equals(expectedType)) {
+                               File from = otherLocalLibrary.getExpectedDir(meta.getSource());
+                               File to = this.getExpectedDir(meta.getSource());
+                               List<File> relatedFiles = otherLocalLibrary
+                                               .getRelatedFiles(luid);
+                               if (!relatedFiles.isEmpty()) {
+                                       pg.setMinMax(0, relatedFiles.size());
+                               }
+
+                               for (File relatedFile : relatedFiles) {
+                                       File target = new File(relatedFile.getAbsolutePath()
+                                                       .replace(from.getAbsolutePath(),
+                                                                       to.getAbsolutePath()));
+                                       if (!relatedFile.equals(target)) {
+                                               target.getParentFile().mkdirs();
+                                               InputStream in = null;
+                                               try {
+                                                       in = new FileInputStream(relatedFile);
+                                                       IOUtils.write(in, target);
+                                               } catch (IOException e) {
+                                                       if (in != null) {
+                                                               try {
+                                                                       in.close();
+                                                               } catch (Exception ee) {
+                                                               }
+                                                       }
+
+                                                       pg.done();
+                                                       throw e;
+                                               }
+                                       }
+
+                                       pg.add(1);
+                               }
+
+                               invalidateInfo();
+                               pg.done();
+                               return;
+                       }
+               }
+
+               super.imprt(other, luid, pg);
+       }
+
+       /**
+        * Return the {@link OutputType} for this {@link Story}.
+        * 
+        * @param meta
+        *            the {@link Story} {@link MetaData}
+        * 
+        * @return the type
+        */
+       private OutputType getOutputType(MetaData meta) {
+               if (meta != null && meta.isImageDocument()) {
+                       return image;
+               }
+
+               return text;
+       }
+
+       /**
+        * Return the default {@link OutputType} for this kind of {@link Story}.
+        * 
+        * @param imageDocument
+        *            TRUE for images document, FALSE for text documents
+        * 
+        * @return the type
+        */
+       public String getOutputType(boolean imageDocument) {
+               if (imageDocument) {
+                       return image.toString();
+               }
+
+               return text.toString();
+       }
+
+       /**
+        * Get the target {@link File} related to the given <tt>.info</tt>
+        * {@link File} and {@link MetaData}.
+        * 
+        * @param meta
+        *            the meta
+        * @param infoFile
+        *            the <tt>.info</tt> {@link File}
+        * 
+        * @return the target {@link File}
+        */
+       private File getTargetFile(MetaData meta, File infoFile) {
+               // Replace .info with whatever is needed:
+               String path = infoFile.getPath();
+               path = path.substring(0, path.length() - ".info".length());
+               String newExt = getOutputType(meta).getDefaultExtension(true);
+
+               return new File(path + newExt);
+       }
+
+       /**
+        * The target (full path) where the {@link Story} related to this
+        * {@link MetaData} should be located on disk for a new {@link Story}.
+        * 
+        * @param key
+        *            the {@link Story} {@link MetaData}
+        * 
+        * @return the target
+        */
+       private File getExpectedFile(MetaData key) {
+               String title = key.getTitle();
+               if (title == null) {
+                       title = "";
+               }
+               title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
+               if (title.length() > 40) {
+                       title = title.substring(0, 40);
+               }
+               return new File(getExpectedDir(key.getSource()),
+                               key.getLuid() + "_" + title);
+       }
+
+       /**
+        * The directory (full path) where the new {@link Story} related to this
+        * {@link MetaData} should be located on disk.
+        * 
+        * @param source
+        *            the type (source)
+        * 
+        * @return the target directory
+        */
+       private File getExpectedDir(String source) {
+               String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
+
+               while (sanitizedSource.startsWith("/")
+                               || sanitizedSource.startsWith("_")) {
+                       if (sanitizedSource.length() > 1) {
+                               sanitizedSource = sanitizedSource.substring(1);
+                       } else {
+                               sanitizedSource = "";
+                       }
+               }
+
+               sanitizedSource = sanitizedSource.replace("/", File.separator);
+
+               if (sanitizedSource.isEmpty()) {
+                       sanitizedSource = "_EMPTY";
+               }
+
+               return new File(baseDir, sanitizedSource);
+       }
+
+       /**
+        * Return the full path to the file to use for the custom cover of this
+        * author.
+        * <p>
+        * One or more of the parent directories <b>MAY</b> not exist.
+        * 
+        * @param author
+        *            the author
+        * 
+        * @return the custom cover file
+        */
+       private File getAuthorCoverFile(String author) {
+               File aDir = new File(baseDir, "_AUTHORS");
+               String hash = StringUtils.getMd5Hash(author);
+               String ext = Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
+               return new File(aDir, hash + "." + ext.toLowerCase());
+       }
+
+       /**
+        * Return the list of files/directories on disk for this {@link Story}.
+        * <p>
+        * If the {@link Story} is not found, and empty list is returned.
+        * 
+        * @param luid
+        *            the {@link Story} LUID
+        * 
+        * @return the list of {@link File}s
+        * 
+        * @throws IOException
+        *             if the {@link Story} was not found
+        */
+       private List<File> getRelatedFiles(String luid) throws IOException {
+               List<File> files = new ArrayList<File>();
+
+               MetaData meta = getInfo(luid);
+               if (meta == null) {
+                       throw new IOException("Story not found: " + luid);
+               }
+
+               File infoFile = getStories(null).get(meta)[0];
+               File targetFile = getStories(null).get(meta)[1];
+
+               files.add(infoFile);
+               files.add(targetFile);
+
+               String readerExt = getOutputType(meta).getDefaultExtension(true);
+               String fileExt = getOutputType(meta).getDefaultExtension(false);
+
+               String path = targetFile.getAbsolutePath();
+               if (readerExt != null && !readerExt.equals(fileExt)) {
+                       path = path.substring(0, path.length() - readerExt.length())
+                                       + fileExt;
+                       File relatedFile = new File(path);
+
+                       if (relatedFile.exists()) {
+                               files.add(relatedFile);
+                       }
+               }
+
+               String coverExt = "." + Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+               File coverFile = new File(path + coverExt);
+               if (!coverFile.exists()) {
+                       coverFile = new File(
+                                       path.substring(0, path.length() - fileExt.length())
+                                                       + coverExt);
+               }
+
+               if (coverFile.exists()) {
+                       files.add(coverFile);
+               }
+
+               return files;
+       }
+
+       /**
+        * Fill the list of stories by reading the content of the local directory
+        * {@link LocalLibrary#baseDir}.
+        * <p>
+        * Will use a cached list when possible (see
+        * {@link BasicLibrary#invalidateInfo()}).
+        * 
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the list of stories (for each item, the first {@link File} is the
+        *         info file, the second file is the target {@link File})
+        */
+       private Map<MetaData, File[]> getStories(Progress pg) {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               Map<MetaData, File[]> stories = this.stories;
+               if (stories == null) {
+                       stories = getStoriesDo(pg);
+                       synchronized (lock) {
+                               if (this.stories == null)
+                                       this.stories = stories;
+                               else
+                                       stories = this.stories;
+                       }
+               }
+
+               pg.done();
+               return stories;
+
+       }
+
+       /**
+        * Actually do the work of {@link LocalLibrary#getStories(Progress)} (i.e.,
+        * do not retrieve the cache).
+        * 
+        * @param pg
+        *            the optional {@link Progress}
+        * 
+        * @return the list of stories (for each item, the first {@link File} is the
+        *         info file, the second file is the target {@link File})
+        */
+       private synchronized Map<MetaData, File[]> getStoriesDo(Progress pg) {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               Map<MetaData, File[]> stories = new HashMap<MetaData, File[]>();
+
+               File[] dirs = baseDir.listFiles(new FileFilter() {
+                       @Override
+                       public boolean accept(File file) {
+                               return file != null && file.isDirectory();
+                       }
+               });
+
+               if (dirs != null) {
+                       Progress pgDirs = new Progress(0, 100 * dirs.length);
+                       pg.addProgress(pgDirs, 100);
+
+                       for (File dir : dirs) {
+                               Progress pgFiles = new Progress();
+                               pgDirs.addProgress(pgFiles, 100);
+                               pgDirs.setName("Loading from: " + dir.getName());
+
+                               addToStories(stories, pgFiles, dir);
+
+                               pgFiles.setName(null);
+                       }
+
+                       pgDirs.setName("Loading directories");
+               }
+
+               pg.done();
+
+               return stories;
+       }
+
+       private void addToStories(Map<MetaData, File[]> stories, Progress pgFiles,
+                       File dir) {
+               File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
+                       @Override
+                       public boolean accept(File file) {
+                               boolean info = file != null && file.isFile()
+                                               && file.getPath().toLowerCase().endsWith(".info");
+                               boolean dir = file != null && file.isDirectory();
+                               boolean isExpandedHtml = new File(file, "index.html").isFile();
+                               return info || (dir && !isExpandedHtml);
+                       }
+               });
+
+               if (pgFiles != null) {
+                       pgFiles.setMinMax(0, infoFilesAndSubdirs.length);
+               }
+
+               for (File infoFileOrSubdir : infoFilesAndSubdirs) {
+                       if (infoFileOrSubdir.isDirectory()) {
+                               addToStories(stories, null, infoFileOrSubdir);
+                       } else {
+                               try {
+                                       MetaData meta = InfoReader.readMeta(infoFileOrSubdir,
+                                                       false);
+                                       try {
+                                               int id = Integer.parseInt(meta.getLuid());
+                                               if (id > lastId) {
+                                                       lastId = id;
+                                               }
+
+                                               stories.put(meta, new File[] { infoFileOrSubdir,
+                                                               getTargetFile(meta, infoFileOrSubdir) });
+                                       } catch (Exception e) {
+                                               // not normal!!
+                                               throw new IOException("Cannot understand the LUID of "
+                                                               + infoFileOrSubdir + ": " + meta.getLuid(), e);
+                                       }
+                               } catch (IOException e) {
+                                       // We should not have not-supported files in the
+                                       // library
+                                       Instance.getInstance().getTraceHandler().error(
+                                                       new IOException("Cannot load file from library: "
+                                                                       + infoFileOrSubdir, e));
+                               }
+                       }
+
+                       if (pgFiles != null) {
+                               pgFiles.add(1);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/MetaResultList.java b/src/be/nikiroo/fanfix/library/MetaResultList.java
new file mode 100644 (file)
index 0000000..8b8a167
--- /dev/null
@@ -0,0 +1,419 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.StringUtils;
+
+public class MetaResultList {
+       /** Max number of items before splitting in [A-B] etc. for eligible items */
+       static private final int MAX = 20;
+
+       private List<MetaData> metas;
+
+       // Lazy lists:
+       // TODO: sync-protect them?
+       private List<String> sources;
+       private List<String> authors;
+       private List<String> tags;
+
+       // can be null (will consider it empty)
+       public MetaResultList(List<MetaData> metas) {
+               if (metas == null) {
+                       metas = new ArrayList<MetaData>();
+               }
+
+               Collections.sort(metas);
+               this.metas = metas;
+       }
+
+       // not NULL
+       // sorted
+       public List<MetaData> getMetas() {
+               return metas;
+       }
+
+       public List<String> getSources() {
+               if (sources == null) {
+                       sources = new ArrayList<String>();
+                       for (MetaData meta : metas) {
+                               if (!sources.contains(meta.getSource()))
+                                       sources.add(meta.getSource());
+                       }
+                       sort(sources);
+               }
+
+               return sources;
+       }
+
+       // A -> (A), A/ -> (A, A/*) if we can find something for "*"
+       public List<String> getSources(String source) {
+               List<String> linked = new ArrayList<String>();
+               if (source != null && !source.isEmpty()) {
+                       if (!source.endsWith("/")) {
+                               linked.add(source);
+                       } else {
+                               linked.add(source.substring(0, source.length() - 1));
+                               for (String src : getSources()) {
+                                       if (src.startsWith(source)) {
+                                               linked.add(src);
+                                       }
+                               }
+                       }
+               }
+
+               sort(linked);
+               return linked;
+       }
+
+       /**
+        * List all the known types (sources) of stories, grouped by directory
+        * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
+        * <p>
+        * Note that an empty item in the list means a non-grouped source (type) --
+        * e.g., you could have for Source_1:
+        * <ul>
+        * <li><tt></tt>: empty, so source is "Source_1"</li>
+        * <li><tt>a</tt>: empty, so source is "Source_1/a"</li>
+        * <li><tt>b</tt>: empty, so source is "Source_1/b"</li>
+        * </ul>
+        * 
+        * @return the grouped list
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Map<String, List<String>> getSourcesGrouped() throws IOException {
+               Map<String, List<String>> map = new TreeMap<String, List<String>>();
+               for (String source : getSources()) {
+                       String name;
+                       String subname;
+
+                       int pos = source.indexOf('/');
+                       if (pos > 0 && pos < source.length() - 1) {
+                               name = source.substring(0, pos);
+                               subname = source.substring(pos + 1);
+
+                       } else {
+                               name = source;
+                               subname = "";
+                       }
+
+                       List<String> list = map.get(name);
+                       if (list == null) {
+                               list = new ArrayList<String>();
+                               map.put(name, list);
+                       }
+                       list.add(subname);
+               }
+
+               return map;
+       }
+
+       public List<String> getAuthors() {
+               if (authors == null) {
+                       authors = new ArrayList<String>();
+                       for (MetaData meta : metas) {
+                               if (!authors.contains(meta.getAuthor()))
+                                       authors.add(meta.getAuthor());
+                       }
+                       sort(authors);
+               }
+
+               return authors;
+       }
+
+       /**
+        * Return the list of authors, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of authors is not too high, only one group with an empty
+        * name and all the authors will be returned.
+        * <p>
+        * If not, the authors will be separated into groups:
+        * <ul>
+        * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0-9</tt>: any author whose name starts with a number</li>
+        * <li><tt>A-C</tt> (for instance): any author whose name starts with
+        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+        * </ul>
+        * Note that the letters used in the groups can vary (except <tt>*</tt> and
+        * <tt>0-9</tt>, which may only be present or not).
+        * 
+        * @return the authors' names, grouped by letter(s)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Map<String, List<String>> getAuthorsGrouped() throws IOException {
+               return group(getAuthors());
+       }
+
+       public List<String> getTags() {
+               if (tags == null) {
+                       tags = new ArrayList<String>();
+                       for (MetaData meta : metas) {
+                               for (String tag : meta.getTags()) {
+                                       if (!tags.contains(tag))
+                                               tags.add(tag);
+                               }
+                       }
+                       sort(tags);
+               }
+
+               return tags;
+       }
+
+       /**
+        * Return the list of tags, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of tags is not too high, only one group with an empty name
+        * and all the tags will be returned.
+        * <p>
+        * If not, the tags will be separated into groups:
+        * <ul>
+        * <li><tt>*</tt>: any tag which name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0-9</tt>: any tag which name starts with a number</li>
+        * <li><tt>A-C</tt> (for instance): any tag which name starts with
+        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+        * </ul>
+        * Note that the letters used in the groups can vary (except <tt>*</tt> and
+        * <tt>0-9</tt>, which may only be present or not).
+        * 
+        * @return the tags' names, grouped by letter(s)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       public Map<String, List<String>> getTagsGrouped() throws IOException {
+               return group(getTags());
+       }
+
+       // helper
+       public List<MetaData> filter(String source, String author, String tag) {
+               List<String> sources = source == null ? null : Arrays.asList(source);
+               List<String> authors = author == null ? null : Arrays.asList(author);
+               List<String> tags = tag == null ? null : Arrays.asList(tag);
+
+               return filter(sources, authors, tags);
+       }
+
+       // null or empty -> no check, rest = must be included
+       // source: a source ending in "/" means "this or any source starting with
+       // this",
+       // i;e., to enable source hierarchy
+       // + sorted
+       public List<MetaData> filter(List<String> sources, List<String> authors,
+                       List<String> tags) {
+               if (sources != null && sources.isEmpty())
+                       sources = null;
+               if (authors != null && authors.isEmpty())
+                       authors = null;
+               if (tags != null && tags.isEmpty())
+                       tags = null;
+
+               // Quick check
+               if (sources == null && authors == null && tags == null) {
+                       return metas;
+               }
+               
+               // allow "sources/" hierarchy
+               if (sources != null) {
+                       List<String> folders = new ArrayList<String>();
+                       List<String> leaves = new ArrayList<String>();
+                       for (String source : sources) {
+                               if (source.endsWith("/")) {
+                                       if (!folders.contains(source))
+                                               folders.add(source);
+                               } else {
+                                       if (!leaves.contains(source))
+                                               leaves.add(source);
+                               }
+                       }
+
+                       sources = leaves;
+                       for (String folder : folders) {
+                               for (String otherLeaf : getSources(folder)) {
+                                       if (!sources.contains(otherLeaf)) {
+                                               sources.add(otherLeaf);
+                                       }
+                               }
+                       }
+               }
+
+               List<MetaData> result = new ArrayList<MetaData>();
+               for (MetaData meta : metas) {
+                       if (sources != null && !sources.contains(meta.getSource())) {
+                               continue;
+                       }
+                       if (authors != null && !authors.contains(meta.getAuthor())) {
+                               continue;
+                       }
+
+                       if (tags != null) {
+                               boolean keep = false;
+                               for (String thisTag : meta.getTags()) {
+                                       if (tags.contains(thisTag))
+                                               keep = true;
+                               }
+
+                               if (!keep)
+                                       continue;
+                       }
+
+                       result.add(meta);
+               }
+
+               Collections.sort(result);
+               return result;
+       }
+
+       /**
+        * Return the list of values, grouped by starting letter(s) if needed.
+        * <p>
+        * If the number of values is not too high, only one group with an empty
+        * name and all the values will be returned (see
+        * {@link MetaResultList#MAX}).
+        * <p>
+        * If not, the values will be separated into groups:
+        * <ul>
+        * <li><tt>*</tt>: any value which name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0-9</tt>: any value which name starts with a number</li>
+        * <li><tt>A-C</tt> (for instance): any value which name starts with
+        * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
+        * </ul>
+        * Note that the letters used in the groups can vary (except <tt>*</tt> and
+        * <tt>0-9</tt>, which may only be present or not).
+        * 
+        * @param values
+        *            the values to group
+        * 
+        * @return the values, grouped by letter(s)
+        * 
+        * @throws IOException
+        *             in case of IOException
+        */
+       private Map<String, List<String>> group(List<String> values)
+                       throws IOException {
+               Map<String, List<String>> groups = new TreeMap<String, List<String>>();
+
+               // If all authors fit the max, just report them as is
+               if (values.size() <= MAX) {
+                       groups.put("", values);
+                       return groups;
+               }
+
+               // Create groups A to Z, which can be empty here
+               for (char car = 'A'; car <= 'Z'; car++) {
+                       groups.put(Character.toString(car), find(values, car));
+               }
+
+               // Collapse them
+               List<String> keys = new ArrayList<String>(groups.keySet());
+               for (int i = 0; i + 1 < keys.size(); i++) {
+                       String keyNow = keys.get(i);
+                       String keyNext = keys.get(i + 1);
+
+                       List<String> now = groups.get(keyNow);
+                       List<String> next = groups.get(keyNext);
+
+                       int currentTotal = now.size() + next.size();
+                       if (currentTotal <= MAX) {
+                               String key = keyNow.charAt(0) + "-"
+                                               + keyNext.charAt(keyNext.length() - 1);
+
+                               List<String> all = new ArrayList<String>();
+                               all.addAll(now);
+                               all.addAll(next);
+
+                               groups.remove(keyNow);
+                               groups.remove(keyNext);
+                               groups.put(key, all);
+
+                               keys.set(i, key); // set the new key instead of key(i)
+                               keys.remove(i + 1); // remove the next, consumed key
+                               i--; // restart at key(i)
+                       }
+               }
+
+               // Add "special" groups
+               groups.put("*", find(values, '*'));
+               groups.put("0-9", find(values, '0'));
+
+               // Prune empty groups
+               keys = new ArrayList<String>(groups.keySet());
+               for (String key : keys) {
+                       if (groups.get(key).isEmpty()) {
+                               groups.remove(key);
+                       }
+               }
+
+               return groups;
+       }
+
+       /**
+        * Get all the authors that start with the given character:
+        * <ul>
+        * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
+        * </li>
+        * <li><tt>0</tt>: any authors whose name starts with a number</li>
+        * <li><tt>A</tt> (any capital latin letter): any author whose name starts
+        * with <tt>A</tt></li>
+        * </ul>
+        * 
+        * @param values
+        *            the full list of authors
+        * @param car
+        *            the starting character, <tt>*</tt>, <tt>0</tt> or a capital
+        *            letter
+        * 
+        * @return the authors that fulfil the starting letter
+        */
+       private List<String> find(List<String> values, char car) {
+               List<String> accepted = new ArrayList<String>();
+               for (String value : values) {
+                       char first = '*';
+                       for (int i = 0; first == '*' && i < value.length(); i++) {
+                               String san = StringUtils.sanitize(value, true, true);
+                               char c = san.charAt(i);
+                               if (c >= '0' && c <= '9') {
+                                       first = '0';
+                               } else if (c >= 'a' && c <= 'z') {
+                                       first = (char) (c - 'a' + 'A');
+                               } else if (c >= 'A' && c <= 'Z') {
+                                       first = c;
+                               }
+                       }
+
+                       if (first == car) {
+                               accepted.add(value);
+                       }
+               }
+
+               return accepted;
+       }
+
+       /**
+        * Sort the given {@link String} values, ignoring case.
+        * 
+        * @param values
+        *            the values to sort
+        */
+       private void sort(List<String> values) {
+               Collections.sort(values, new Comparator<String>() {
+                       @Override
+                       public int compare(String o1, String o2) {
+                               return ("" + o1).compareToIgnoreCase("" + o2);
+                       }
+               });
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/RemoteLibrary.java b/src/be/nikiroo/fanfix/library/RemoteLibrary.java
new file mode 100644 (file)
index 0000000..9fa8c66
--- /dev/null
@@ -0,0 +1,590 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ConnectActionClientObject;
+
+/**
+ * This {@link BasicLibrary} will access a remote server to list the available
+ * stories, and download the ones you try to load to the local directory
+ * specified in the configuration.
+ * <p>
+ * This remote library uses a custom fanfix:// protocol.
+ * 
+ * @author niki
+ */
+public class RemoteLibrary extends BasicLibrary {
+       interface RemoteAction {
+               public void action(ConnectActionClientObject action) throws Exception;
+       }
+
+       class RemoteConnectAction extends ConnectActionClientObject {
+               public RemoteConnectAction() throws IOException {
+                       super(host, port, key);
+               }
+
+               @Override
+               public Object send(Object data)
+                               throws IOException, NoSuchFieldException, NoSuchMethodException,
+                               ClassNotFoundException {
+                       Object rep = super.send(data);
+                       if (rep instanceof RemoteLibraryException) {
+                               RemoteLibraryException remoteEx = (RemoteLibraryException) rep;
+                               throw remoteEx.unwrapException();
+                       }
+
+                       return rep;
+               }
+       }
+
+       private String host;
+       private int port;
+       private final String key;
+       private final String subkey;
+
+       // informative only (server will make the actual checks)
+       private boolean rw;
+
+       /**
+        * Create a {@link RemoteLibrary} linked to the given server.
+        * <p>
+        * Note that the key is structured:
+        * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>|<b>wl</b>)(|<b>rw</b>)</tt>
+        * <p>
+        * Note that anything before the first pipe (<tt>|</tt>) character is
+        * considered to be the encryption key, anything after that character is
+        * called the subkey (including the other pipe characters and flags!).
+        * <p>
+        * This is important because the subkey (including the pipe characters and
+        * flags) must be present as-is in the server configuration file to be
+        * allowed.
+        * <ul>
+        * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
+        * server</li>
+        * <li><b><i>yyy</i></b>: the secondary key</li>
+        * <li><b>rw</b>: flag to allow read and write access if it is not the
+        * default on this server</li>
+        * <li><b>wl</b>: flag to allow access to all the stories (bypassing the
+        * whitelist if it exists)</li>
+        * </ul>
+        * <p>
+        * Some examples:
+        * <ul>
+        * <li><b>my_key</b>: normal connection, will take the default server
+        * options</li>
+        * <li><b>my_key|agzyzz|wl</b>: will ask to bypass the white list (if it
+        * exists)</li>
+        * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
+        * is read-only)</li>
+        * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
+        * list bypass</li>
+        * </ul>
+        * 
+        * @param key
+        *            the key that will allow us to exchange information with the
+        *            server
+        * @param host
+        *            the host to contact or NULL for localhost
+        * @param port
+        *            the port to contact it on
+        */
+       public RemoteLibrary(String key, String host, int port) {
+               int index = -1;
+               if (key != null) {
+                       index = key.indexOf('|');
+               }
+
+               if (index >= 0) {
+                       this.key = key.substring(0, index);
+                       this.subkey = key.substring(index + 1);
+               } else {
+                       this.key = key;
+                       this.subkey = "";
+               }
+
+               if (host.startsWith("fanfix://")) {
+                       host = host.substring("fanfix://".length());
+               }
+
+               this.host = host;
+               this.port = port;
+       }
+
+       @Override
+       public String getLibraryName() {
+               return (rw ? "[READ-ONLY] " : "") + "fanfix://" + host + ":" + port;
+       }
+
+       @Override
+       public Status getStatus() {
+               Instance.getInstance().getTraceHandler()
+                               .trace("Getting remote lib status...");
+               Status status = getStatusDo();
+               Instance.getInstance().getTraceHandler()
+                               .trace("Remote lib status: " + status);
+               return status;
+       }
+
+       private Status getStatusDo() {
+               final Status[] result = new Status[1];
+
+               result[0] = Status.INVALID;
+
+               try {
+                       new RemoteConnectAction() {
+                               @Override
+                               public void action(Version serverVersion) throws Exception {
+                                       Object rep = send(new Object[] { subkey, "PING" });
+
+                                       if ("r/w".equals(rep)) {
+                                               rw = true;
+                                               result[0] = Status.READ_WRITE;
+                                       } else if ("r/o".equals(rep)) {
+                                               rw = false;
+                                               result[0] = Status.READ_ONLY;
+                                       } else {
+                                               result[0] = Status.UNAUTHORIZED;
+                                       }
+                               }
+
+                               @Override
+                               protected void onError(Exception e) {
+                                       if (e instanceof SSLException) {
+                                               result[0] = Status.UNAUTHORIZED;
+                                       } else {
+                                               result[0] = Status.UNAVAILABLE;
+                                       }
+                               }
+                       }.connect();
+               } catch (UnknownHostException e) {
+                       result[0] = Status.INVALID;
+               } catch (IllegalArgumentException e) {
+                       result[0] = Status.INVALID;
+               } catch (Exception e) {
+                       result[0] = Status.UNAVAILABLE;
+               }
+
+               return result[0];
+       }
+
+       @Override
+       public Image getCover(final String luid) throws IOException {
+               final Image[] result = new Image[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Object rep = action
+                                               .send(new Object[] { subkey, "GET_COVER", luid });
+                               result[0] = (Image) rep;
+                       }
+               });
+
+               return result[0];
+       }
+
+       @Override
+       public Image getCustomSourceCover(final String source) throws IOException {
+               return getCustomCover(source, "SOURCE");
+       }
+
+       @Override
+       public Image getCustomAuthorCover(final String author) throws IOException {
+               return getCustomCover(author, "AUTHOR");
+       }
+
+       // type: "SOURCE" or "AUTHOR"
+       private Image getCustomCover(final String source, final String type)
+                       throws IOException {
+               final Image[] result = new Image[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Object rep = action.send(new Object[] { subkey,
+                                               "GET_CUSTOM_COVER", type, source });
+                               result[0] = (Image) rep;
+                       }
+               });
+
+               return result[0];
+       }
+
+       @Override
+       public synchronized Story getStory(final String luid, Progress pg)
+                       throws IOException {
+               final Progress pgF = pg;
+               final Story[] result = new Story[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+                               if (pg == null) {
+                                       pg = new Progress();
+                               }
+
+                               Object rep = action
+                                               .send(new Object[] { subkey, "GET_STORY", luid });
+
+                               MetaData meta = null;
+                               if (rep instanceof MetaData) {
+                                       meta = (MetaData) rep;
+                                       if (meta.getWords() <= Integer.MAX_VALUE) {
+                                               pg.setMinMax(0, (int) meta.getWords());
+                                       }
+                               }
+
+                               List<Object> list = new ArrayList<Object>();
+                               for (Object obj = action.send(null); obj != null; obj = action
+                                               .send(null)) {
+                                       list.add(obj);
+                                       pg.add(1);
+                               }
+
+                               result[0] = RemoteLibraryServer.rebuildStory(list);
+                               pg.done();
+                       }
+               });
+
+               return result[0];
+       }
+
+       @Override
+       public synchronized Story save(final Story story, final String luid,
+                       Progress pg) throws IOException {
+
+               final String[] luidSaved = new String[1];
+               Progress pgSave = new Progress();
+               Progress pgRefresh = new Progress();
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               pg.setMinMax(0, 10);
+               pg.addProgress(pgSave, 9);
+               pg.addProgress(pgRefresh, 1);
+
+               final Progress pgF = pgSave;
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+                               if (story.getMeta().getWords() <= Integer.MAX_VALUE) {
+                                       pg.setMinMax(0, (int) story.getMeta().getWords());
+                               }
+
+                               action.send(new Object[] { subkey, "SAVE_STORY", luid });
+
+                               List<Object> list = RemoteLibraryServer.breakStory(story);
+                               for (Object obj : list) {
+                                       action.send(obj);
+                                       pg.add(1);
+                               }
+
+                               luidSaved[0] = (String) action.send(null);
+
+                               pg.done();
+                       }
+               });
+
+               // because the meta changed:
+               MetaData meta = getInfo(luidSaved[0]);
+               if (story.getMeta().getClass() != null) {
+                       // If already available locally:
+                       meta.setCover(story.getMeta().getCover());
+               } else {
+                       // If required:
+                       meta.setCover(getCover(meta.getLuid()));
+               }
+               story.setMeta(meta);
+
+               pg.done();
+
+               return story;
+       }
+
+       @Override
+       public synchronized void delete(final String luid) throws IOException {
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               action.send(new Object[] { subkey, "DELETE_STORY", luid });
+                       }
+               });
+       }
+
+       @Override
+       public void setSourceCover(final String source, final String luid)
+                       throws IOException {
+               setCover(source, luid, "SOURCE");
+       }
+
+       @Override
+       public void setAuthorCover(final String author, final String luid)
+                       throws IOException {
+               setCover(author, luid, "AUTHOR");
+       }
+
+       // type = "SOURCE" | "AUTHOR"
+       private void setCover(final String value, final String luid,
+                       final String type) throws IOException {
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               action.send(new Object[] { subkey, "SET_COVER", type, value,
+                                               luid });
+                       }
+               });
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       public MetaData imprt(final URL url, Progress pg) throws IOException {
+               // Import the file locally if it is actually a file
+
+               if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
+                       return super.imprt(url, pg);
+               }
+
+               // Import it remotely if it is an URL
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               final Progress pgF = pg;
+               final String[] luid = new String[1];
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+
+                               Object rep = action.send(
+                                               new Object[] { subkey, "IMPORT", url.toString() });
+
+                               while (true) {
+                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
+                                               break;
+                                       }
+
+                                       rep = action.send(null);
+                               }
+
+                               pg.done();
+                               luid[0] = (String) rep;
+                       }
+               });
+
+               if (luid[0] == null) {
+                       throw new IOException("Remote failure");
+               }
+
+               pg.done();
+               return getInfo(luid[0]);
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       protected synchronized void changeSTA(final String luid,
+                       final String newSource, final String newTitle,
+                       final String newAuthor, Progress pg) throws IOException {
+
+               final Progress pgF = pg == null ? new Progress() : pg;
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+
+                               Object rep = action.send(new Object[] { subkey, "CHANGE_STA",
+                                               luid, newSource, newTitle, newAuthor });
+                               while (true) {
+                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
+                                               break;
+                                       }
+
+                                       rep = action.send(null);
+                               }
+                       }
+               });
+       }
+
+       @Override
+       public File getFile(final String luid, Progress pg) {
+               throw new java.lang.InternalError(
+                               "Operation not supportorted on remote Libraries");
+       }
+
+       /**
+        * Stop the server.
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        * @throws SSLException
+        *             when the key was not accepted
+        */
+       public void exit() throws IOException, SSLException {
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               action.send(new Object[] { subkey, "EXIT" });
+                               Thread.sleep(100);
+                       }
+               });
+       }
+
+       @Override
+       public MetaData getInfo(String luid) throws IOException {
+               List<MetaData> metas = getMetasList(luid, null);
+               if (!metas.isEmpty()) {
+                       return metas.get(0);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
+               return getMetasList("*", pg);
+       }
+
+       @Override
+       protected void updateInfo(MetaData meta) {
+               // Will be taken care of directly server side
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               // Will be taken care of directly server side
+       }
+
+       // The following methods are only used by Save and Delete in BasicLibrary:
+
+       @Override
+       protected int getNextId() {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       //
+
+       /**
+        * Return the meta of the given story or a list of all known metas if the
+        * luid is "*".
+        * <p>
+        * Will not get the covers.
+        * 
+        * @param luid
+        *            the luid of the story or *
+        * @param pg
+        *            the optional progress
+        * 
+        * @return the metas
+        * 
+        * @throws IOException
+        *             in case of I/O error or bad key (SSLException)
+        */
+       private List<MetaData> getMetasList(final String luid, Progress pg)
+                       throws IOException {
+               final Progress pgF = pg;
+               final List<MetaData> metas = new ArrayList<MetaData>();
+
+               connectRemoteAction(new RemoteAction() {
+                       @Override
+                       public void action(ConnectActionClientObject action)
+                                       throws Exception {
+                               Progress pg = pgF;
+                               if (pg == null) {
+                                       pg = new Progress();
+                               }
+
+                               Object rep = action
+                                               .send(new Object[] { subkey, "GET_METADATA", luid });
+
+                               while (true) {
+                                       if (!RemoteLibraryServer.updateProgress(pg, rep)) {
+                                               break;
+                                       }
+
+                                       rep = action.send(null);
+                               }
+
+                               if (rep instanceof MetaData[]) {
+                                       for (MetaData meta : (MetaData[]) rep) {
+                                               metas.add(meta);
+                                       }
+                               } else if (rep != null) {
+                                       metas.add((MetaData) rep);
+                               }
+                       }
+               });
+
+               return metas;
+       }
+
+       private void connectRemoteAction(final RemoteAction runAction)
+                       throws IOException {
+               final IOException[] err = new IOException[1];
+               try {
+                       final RemoteConnectAction[] array = new RemoteConnectAction[1];
+                       RemoteConnectAction ra = new RemoteConnectAction() {
+                               @Override
+                               public void action(Version serverVersion) throws Exception {
+                                       runAction.action(array[0]);
+                               }
+
+                               @Override
+                               protected void onError(Exception e) {
+                                       if (!(e instanceof IOException)) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                               return;
+                                       }
+
+                                       err[0] = (IOException) e;
+                               }
+                       };
+                       array[0] = ra;
+                       ra.connect();
+               } catch (Exception e) {
+                       err[0] = (IOException) e;
+               }
+
+               if (err[0] != null) {
+                       throw err[0];
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/RemoteLibraryException.java b/src/be/nikiroo/fanfix/library/RemoteLibraryException.java
new file mode 100644 (file)
index 0000000..4cbb631
--- /dev/null
@@ -0,0 +1,100 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.IOException;
+
+/**
+ * Exceptions sent from remote to local.
+ * 
+ * @author niki
+ */
+public class RemoteLibraryException extends IOException {
+       private static final long serialVersionUID = 1L;
+
+       private boolean wrapped;
+
+       @SuppressWarnings("unused")
+       private RemoteLibraryException() {
+               // for serialization purposes
+       }
+
+       /**
+        * Wrap an {@link IOException} to allow it to pass across the network.
+        * 
+        * @param cause
+        *            the exception to wrap
+        * @param remote
+        *            this exception is used to send the contained
+        *            {@link IOException} to the other end of the network
+        */
+       public RemoteLibraryException(IOException cause, boolean remote) {
+               this(null, cause, remote);
+       }
+
+       /**
+        * Wrap an {@link IOException} to allow it to pass across the network.
+        * 
+        * @param message
+        *            the error message
+        * @param wrapped
+        *            this exception is used to send the contained
+        *            {@link IOException} to the other end of the network
+        */
+       public RemoteLibraryException(String message, boolean wrapped) {
+               this(message, null, wrapped);
+       }
+
+       /**
+        * Wrap an {@link IOException} to allow it to pass across the network.
+        * 
+        * @param message
+        *            the error message
+        * @param cause
+        *            the exception to wrap
+        * @param wrapped
+        *            this exception is used to send the contained
+        *            {@link IOException} to the other end of the network
+        */
+       public RemoteLibraryException(String message, IOException cause,
+                       boolean wrapped) {
+               super(message, cause);
+               this.wrapped = wrapped;
+       }
+
+       /**
+        * Return the actual exception we should return to the client code. It can
+        * be:
+        * <ul>
+        * <li>the <tt>cause</tt> if {@link RemoteLibraryException#isWrapped()} is
+        * TRUE</li>
+        * <li><tt>this</tt> if {@link RemoteLibraryException#isWrapped()} is FALSE
+        * (</li>
+        * <li><tt>this</tt> if the <tt>cause</tt> is NULL (so we never return NULL)
+        * </li>
+        * </ul>
+        * It is never NULL.
+        * 
+        * @return the unwrapped exception or <tt>this</tt>, never NULL
+        */
+       public synchronized IOException unwrapException() {
+               Throwable ex = super.getCause();
+               if (!isWrapped() || !(ex instanceof IOException)) {
+                       ex = this;
+               }
+
+               return (IOException) ex;
+       }
+
+       /**
+        * This exception is used to send the contained {@link IOException} to the
+        * other end of the network.
+        * <p>
+        * In other words, do not use <tt>this</tt> exception in client code when it
+        * has reached the other end of the network, but use its cause instead (see
+        * {@link RemoteLibraryException#unwrapException()}).
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isWrapped() {
+               return wrapped;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java b/src/be/nikiroo/fanfix/library/RemoteLibraryServer.java
new file mode 100644 (file)
index 0000000..c150a01
--- /dev/null
@@ -0,0 +1,554 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.net.ssl.SSLException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Progress.ProgressListener;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
+import be.nikiroo.utils.serial.server.ConnectActionServerObject;
+import be.nikiroo.utils.serial.server.ServerObject;
+
+/**
+ * Create a new remote server that will listen for orders on the given port.
+ * <p>
+ * The available commands are given as arrays of objects (first item is the
+ * command, the rest are the arguments).
+ * <p>
+ * All the commands are always prefixed by the subkey (which can be EMPTY if
+ * none).
+ * <p>
+ * <ul>
+ * <li>PING: will return the mode if the key is accepted (mode can be: "r/o" or
+ * "r/w")</li>
+ * <li>GET_METADATA *: will return the metadata of all the stories in the
+ * library (array)</li> *
+ * <li>GET_METADATA [luid]: will return the metadata of the story of LUID luid</li>
+ * <li>GET_STORY [luid]: will return the given story if it exists (or NULL if
+ * not)</li>
+ * <li>SAVE_STORY [luid]: save the story (that must be sent just after the
+ * command) with the given LUID, then return the LUID</li>
+ * <li>IMPORT [url]: save the story found at the given URL, then return the LUID
+ * </li>
+ * <li>DELETE_STORY [luid]: delete the story of LUID luid</li>
+ * <li>GET_COVER [luid]: return the cover of the story</li>
+ * <li>GET_CUSTOM_COVER ["SOURCE"|"AUTHOR"] [source]: return the cover for this
+ * source/author</li>
+ * <li>SET_COVER ["SOURCE"|"AUTHOR"] [value] [luid]: set the default cover for
+ * the given source/author to the cover of the story denoted by luid</li>
+ * <li>CHANGE_SOURCE [luid] [new source]: change the source of the story of LUID
+ * luid</li>
+ * <li>EXIT: stop the server</li>
+ * </ul>
+ * 
+ * @author niki
+ */
+public class RemoteLibraryServer extends ServerObject {
+       private Map<Long, String> commands = new HashMap<Long, String>();
+       private Map<Long, Long> times = new HashMap<Long, Long>();
+       private Map<Long, Boolean> wls = new HashMap<Long, Boolean>();
+       private Map<Long, Boolean> rws = new HashMap<Long, Boolean>();
+
+       /**
+        * Create a new remote server (will not be active until
+        * {@link RemoteLibraryServer#start()} is called).
+        * <p>
+        * Note: the key we use here is the encryption key (it must not contain a
+        * subkey).
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public RemoteLibraryServer() throws IOException {
+               super("Fanfix remote library",
+                               Instance.getInstance().getConfig()
+                                               .getInteger(Config.SERVER_PORT),
+                               Instance.getInstance().getConfig()
+                                               .getString(Config.SERVER_KEY));
+               
+               setTraceHandler(Instance.getInstance().getTraceHandler());
+       }
+
+       @Override
+       protected Object onRequest(ConnectActionServerObject action,
+                       Version clientVersion, Object data, long id) throws Exception {
+               long start = new Date().getTime();
+
+               // defaults are positive (as previous versions without the feature)
+               boolean rw = true;
+               boolean wl = true;
+
+               String subkey = "";
+               String command = "";
+               Object[] args = new Object[0];
+               if (data instanceof Object[]) {
+                       Object[] dataArray = (Object[]) data;
+                       if (dataArray.length > 0) {
+                               subkey = "" + dataArray[0];
+                       }
+                       if (dataArray.length > 1) {
+                               command = "" + dataArray[1];
+
+                               args = new Object[dataArray.length - 2];
+                               for (int i = 2; i < dataArray.length; i++) {
+                                       args[i - 2] = dataArray[i];
+                               }
+                       }
+               }
+
+               List<String> whitelist = Instance.getInstance().getConfig().getList(Config.SERVER_WHITELIST);
+               if (whitelist == null) {
+                       whitelist = new ArrayList<String>();
+               }
+
+               if (whitelist.isEmpty()) {
+                       wl = false;
+               }
+
+               rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW, rw);
+               if (!subkey.isEmpty()) {
+                       List<String> allowed = Instance.getInstance().getConfig().getList(Config.SERVER_ALLOWED_SUBKEYS);
+                       if (allowed.contains(subkey)) {
+                               if ((subkey + "|").contains("|rw|")) {
+                                       rw = true;
+                               }
+                               if ((subkey + "|").contains("|wl|")) {
+                                       wl = false; // |wl| = bypass whitelist
+                                       whitelist = new ArrayList<String>();
+                               }
+                       }
+               }
+
+               String mode = display(wl, rw);
+
+               String trace = mode + "[ " + command + "] ";
+               for (Object arg : args) {
+                       trace += arg + " ";
+               }
+               long now = System.currentTimeMillis();
+               System.out.println(StringUtils.fromTime(now) + ": " + trace);
+
+               Object rep = null;
+               try {
+                       rep = doRequest(action, command, args, rw, whitelist);
+               } catch (IOException e) {
+                       rep = new RemoteLibraryException(e, true);
+               }
+
+               commands.put(id, command);
+               wls.put(id, wl);
+               rws.put(id, rw);
+               times.put(id, (new Date().getTime() - start));
+
+               return rep;
+       }
+
+       private String display(boolean whitelist, boolean rw) {
+               String mode = "";
+               if (!rw) {
+                       mode += "RO: ";
+               }
+               if (whitelist) {
+                       mode += "WL: ";
+               }
+
+               return mode;
+       }
+
+       @Override
+       protected void onRequestDone(long id, long bytesReceived, long bytesSent) {
+               boolean whitelist = wls.get(id);
+               boolean rw = rws.get(id);
+               wls.remove(id);
+               rws.remove(id);
+
+               String rec = StringUtils.formatNumber(bytesReceived) + "b";
+               String sent = StringUtils.formatNumber(bytesSent) + "b";
+               long now = System.currentTimeMillis();
+               System.out.println(StringUtils.fromTime(now)
+                               + ": "
+                               + String.format("%s[>%s]: (%s sent, %s rec) in %d ms",
+                                               display(whitelist, rw), commands.get(id), sent, rec,
+                                               times.get(id)));
+
+               commands.remove(id);
+               times.remove(id);
+       }
+
+       private Object doRequest(ConnectActionServerObject action, String command,
+                       Object[] args, boolean rw, List<String> whitelist)
+                       throws NoSuchFieldException, NoSuchMethodException,
+                       ClassNotFoundException, IOException {
+               if ("PING".equals(command)) {
+                       return rw ? "r/w" : "r/o";
+               } else if ("GET_METADATA".equals(command)) {
+                       List<MetaData> metas = new ArrayList<MetaData>();
+
+                       if ("*".equals(args[0])) {
+                               Progress pg = createPgForwarder(action);
+
+                               for (MetaData meta : Instance.getInstance().getLibrary().getMetas(pg)) {
+                                       metas.add(removeCover(meta));
+                               }
+
+                               forcePgDoneSent(pg);
+                       } else {
+                               MetaData meta = Instance.getInstance().getLibrary().getInfo((String) args[0]);
+                               MetaData light;
+                               if (meta.getCover() == null) {
+                                       light = meta;
+                               } else {
+                                       light = meta.clone();
+                                       light.setCover(null);
+                               }
+
+                               metas.add(light);
+                       }
+
+                       if (!whitelist.isEmpty()) {
+                               for (int i = 0; i < metas.size(); i++) {
+                                       if (!whitelist.contains(metas.get(i).getSource())) {
+                                               metas.remove(i);
+                                               i--;
+                                       }
+                               }
+                       }
+
+                       return metas.toArray(new MetaData[0]);
+               } else if ("GET_STORY".equals(command)) {
+                       MetaData meta = Instance.getInstance().getLibrary().getInfo((String) args[0]);
+                       if (meta == null) {
+                               return null;
+                       }
+
+                       if (!whitelist.isEmpty()) {
+                               if (!whitelist.contains(meta.getSource())) {
+                                       return null;
+                               }
+                       }
+
+                       meta = meta.clone();
+                       meta.setCover(null);
+
+                       action.send(meta);
+                       action.rec();
+
+                       Story story = Instance.getInstance().getLibrary().getStory((String) args[0], null);
+                       for (Object obj : breakStory(story)) {
+                               action.send(obj);
+                               action.rec();
+                       }
+               } else if ("SAVE_STORY".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException("Read-Only remote library: "
+                                               + args[0], false);
+                       }
+
+                       List<Object> list = new ArrayList<Object>();
+
+                       action.send(null);
+                       Object obj = action.rec();
+                       while (obj != null) {
+                               list.add(obj);
+                               action.send(null);
+                               obj = action.rec();
+                       }
+
+                       Story story = rebuildStory(list);
+                       Instance.getInstance().getLibrary().save(story, (String) args[0], null);
+                       return story.getMeta().getLuid();
+               } else if ("IMPORT".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException("Read-Only remote library: "
+                                               + args[0], false);
+                       }
+
+                       Progress pg = createPgForwarder(action);
+                       MetaData meta = Instance.getInstance().getLibrary().imprt(new URL((String) args[0]), pg);
+                       forcePgDoneSent(pg);
+                       return meta.getLuid();
+               } else if ("DELETE_STORY".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException("Read-Only remote library: "
+                                               + args[0], false);
+                       }
+
+                       Instance.getInstance().getLibrary().delete((String) args[0]);
+               } else if ("GET_COVER".equals(command)) {
+                       return Instance.getInstance().getLibrary().getCover((String) args[0]);
+               } else if ("GET_CUSTOM_COVER".equals(command)) {
+                       if ("SOURCE".equals(args[0])) {
+                               return Instance.getInstance().getLibrary().getCustomSourceCover((String) args[1]);
+                       } else if ("AUTHOR".equals(args[0])) {
+                               return Instance.getInstance().getLibrary().getCustomAuthorCover((String) args[1]);
+                       } else {
+                               return null;
+                       }
+               } else if ("SET_COVER".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException("Read-Only remote library: "
+                                               + args[0] + ", " + args[1], false);
+                       }
+
+                       if ("SOURCE".equals(args[0])) {
+                               Instance.getInstance().getLibrary().setSourceCover((String) args[1], (String) args[2]);
+                       } else if ("AUTHOR".equals(args[0])) {
+                               Instance.getInstance().getLibrary().setAuthorCover((String) args[1], (String) args[2]);
+                       }
+               } else if ("CHANGE_STA".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException("Read-Only remote library: " + args[0] + ", " + args[1], false);
+                       }
+
+                       Progress pg = createPgForwarder(action);
+                       Instance.getInstance().getLibrary().changeSTA((String) args[0], (String) args[1], (String) args[2],
+                                       (String) args[3], pg);
+                       forcePgDoneSent(pg);
+               } else if ("EXIT".equals(command)) {
+                       if (!rw) {
+                               throw new RemoteLibraryException(
+                                               "Read-Only remote library: EXIT", false);
+                       }
+
+                       stop(10000, false);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected void onError(Exception e) {
+               if (e instanceof SSLException) {
+                       long now = System.currentTimeMillis();
+                       System.out.println(StringUtils.fromTime(now) + ": "
+                                       + "[Client connection refused (bad key)]");
+               } else {
+                       getTraceHandler().error(e);
+               }
+       }
+
+       /**
+        * Break a story in multiple {@link Object}s for easier serialisation.
+        * 
+        * @param story
+        *            the {@link Story} to break
+        * 
+        * @return the list of {@link Object}s
+        */
+       static List<Object> breakStory(Story story) {
+               List<Object> list = new ArrayList<Object>();
+
+               story = story.clone();
+               list.add(story);
+
+               if (story.getMeta().isImageDocument()) {
+                       for (Chapter chap : story) {
+                               list.add(chap);
+                               list.addAll(chap.getParagraphs());
+                               chap.setParagraphs(new ArrayList<Paragraph>());
+                       }
+                       story.setChapters(new ArrayList<Chapter>());
+               }
+
+               return list;
+       }
+
+       /**
+        * Rebuild a story from a list of broke up {@link Story} parts.
+        * 
+        * @param list
+        *            the list of {@link Story} parts
+        * 
+        * @return the reconstructed {@link Story}
+        */
+       static Story rebuildStory(List<Object> list) {
+               Story story = null;
+               Chapter chap = null;
+
+               for (Object obj : list) {
+                       if (obj instanceof Story) {
+                               story = (Story) obj;
+                       } else if (obj instanceof Chapter) {
+                               chap = (Chapter) obj;
+                               story.getChapters().add(chap);
+                       } else if (obj instanceof Paragraph) {
+                               chap.getParagraphs().add((Paragraph) obj);
+                       }
+               }
+
+               return story;
+       }
+
+       /**
+        * Update the {@link Progress} with the adequate {@link Object} received
+        * from the network via {@link RemoteLibraryServer}.
+        * 
+        * @param pg
+        *            the {@link Progress} to update
+        * @param rep
+        *            the object received from the network
+        * 
+        * @return TRUE if it was a progress event, FALSE if not
+        */
+       static boolean updateProgress(Progress pg, Object rep) {
+               boolean updateProgress = false;
+               if (rep instanceof Integer[] && ((Integer[]) rep).length == 3)
+                       updateProgress = true;
+               if (rep instanceof Object[] && ((Object[]) rep).length >= 5
+                               && "UPDATE".equals(((Object[]) rep)[0]))
+                       updateProgress = true;
+
+               if (updateProgress) {
+                       Object[] a = (Object[]) rep;
+
+                       int offset = 0;
+                       if (a[0] instanceof String) {
+                               offset = 1;
+                       }
+
+                       int min = (Integer) a[0 + offset];
+                       int max = (Integer) a[1 + offset];
+                       int progress = (Integer) a[2 + offset];
+                       
+                       Object meta = null;
+                       if (a.length > (3 + offset)) {
+                               meta = a[3 + offset];
+                       }
+                       
+                       String name = null;
+                       if (a.length > (4 + offset)) {
+                               name = a[4 + offset] == null ? "" : a[4 + offset].toString();
+                       }
+                       
+
+                       if (min >= 0 && min <= max) {
+                               pg.setName(name);
+                               pg.setMinMax(min, max);
+                               pg.setProgress(progress);
+                               if (meta != null) {
+                                       pg.put("meta", meta);
+                               }
+
+                               return true;
+                       }
+               }
+
+               return false;
+       }
+
+       /**
+        * Create a {@link Progress} that will forward its progress over the
+        * network.
+        * 
+        * @param action
+        *            the {@link ConnectActionServerObject} to use to forward it
+        * 
+        * @return the {@link Progress}
+        */
+       private Progress createPgForwarder(final ConnectActionServerObject action) {
+               final Boolean[] isDoneForwarded = new Boolean[] { false };
+               final Progress pg = new Progress() {
+                       @Override
+                       public boolean isDone() {
+                               return isDoneForwarded[0];
+                       }
+               };
+
+               final Integer[] p = new Integer[] { -1, -1, -1 };
+               final Object[] pMeta = new MetaData[1];
+               final String[] pName = new String[1];
+               final Long[] lastTime = new Long[] { new Date().getTime() };
+               pg.addProgressListener(new ProgressListener() {
+                       @Override
+                       public void progress(Progress progress, String name) {
+                               Object meta = pg.get("meta");
+                               if (meta instanceof MetaData) {
+                                       meta = removeCover((MetaData)meta);
+                               }
+                               
+                               int min = pg.getMin();
+                               int max = pg.getMax();
+                               int rel = min
+                                               + (int) Math.round(pg.getRelativeProgress()
+                                                               * (max - min));
+                               
+                               boolean samePg = p[0] == min && p[1] == max && p[2] == rel;
+                               
+                               // Do not re-send the same value twice over the wire,
+                               // unless more than 2 seconds have elapsed (to maintain the
+                               // connection)
+                               if (!samePg || !same(pMeta[0], meta)
+                                               || !same(pName[0], name) //
+                                               || (new Date().getTime() - lastTime[0] > 2000)) {
+                                       p[0] = min;
+                                       p[1] = max;
+                                       p[2] = rel;
+                                       pMeta[0] = meta;
+                                       pName[0] = name;
+
+                                       try {
+                                               action.send(new Object[] { "UPDATE", min, max, rel,
+                                                               meta, name });
+                                               action.rec();
+                                       } catch (Exception e) {
+                                               getTraceHandler().error(e);
+                                       }
+
+                                       lastTime[0] = new Date().getTime();
+                               }
+
+                               isDoneForwarded[0] = (pg.getProgress() >= pg.getMax());
+                       }
+               });
+
+               return pg;
+       }
+       
+       private boolean same(Object obj1, Object obj2) {
+               if (obj1 == null || obj2 == null)
+                       return obj1 == null && obj2 == null;
+
+               return obj1.equals(obj2);
+       }
+
+       // with 30 seconds timeout
+       private void forcePgDoneSent(Progress pg) {
+               long start = new Date().getTime();
+               pg.done();
+               while (!pg.isDone() && new Date().getTime() - start < 30000) {
+                       try {
+                               Thread.sleep(100);
+                       } catch (InterruptedException e) {
+                               getTraceHandler().error(e);
+                       }
+               }
+       }
+       
+       private MetaData removeCover(MetaData meta) {
+               MetaData light = null;
+               if (meta != null) {
+                       if (meta.getCover() == null) {
+                               light = meta;
+                       } else {
+                               light = meta.clone();
+                               light.setCover(null);
+                       }
+               }
+               
+               return light;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/WebLibrary.java b/src/be/nikiroo/fanfix/library/WebLibrary.java
new file mode 100644 (file)
index 0000000..7f77583
--- /dev/null
@@ -0,0 +1,290 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.JsonIO;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * This {@link BasicLibrary} will access a remote server to list the available
+ * stories, and download the ones you try to load to the local directory
+ * specified in the configuration.
+ * <p>
+ * This remote library uses http:// or https://.
+ * 
+ * @author niki
+ */
+public class WebLibrary extends BasicLibrary {
+       private String host;
+       private int port;
+       private final String key;
+       private final String subkey;
+
+       // informative only (server will make the actual checks)
+       private boolean rw;
+
+       /**
+        * Create a {@link RemoteLibrary} linked to the given server.
+        * <p>
+        * Note that the key is structured:
+        * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>|<b>wl</b>)(|<b>rw</b>)</tt>
+        * <p>
+        * Note that anything before the first pipe (<tt>|</tt>) character is
+        * considered to be the encryption key, anything after that character is
+        * called the subkey (including the other pipe characters and flags!).
+        * <p>
+        * This is important because the subkey (including the pipe characters and
+        * flags) must be present as-is in the server configuration file to be
+        * allowed.
+        * <ul>
+        * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
+        * server</li>
+        * <li><b><i>yyy</i></b>: the secondary key</li>
+        * <li><b>rw</b>: flag to allow read and write access if it is not the
+        * default on this server</li>
+        * <li><b>wl</b>: flag to allow access to all the stories (bypassing the
+        * whitelist if it exists)</li>
+        * </ul>
+        * <p>
+        * Some examples:
+        * <ul>
+        * <li><b>my_key</b>: normal connection, will take the default server
+        * options</li>
+        * <li><b>my_key|agzyzz|wl</b>: will ask to bypass the white list (if it
+        * exists)</li>
+        * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
+        * is read-only)</li>
+        * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
+        * list bypass</li>
+        * </ul>
+        * 
+        * @param key
+        *            the key that will allow us to exchange information with the
+        *            server
+        * @param host
+        *            the host to contact or NULL for localhost
+        * @param port
+        *            the port to contact it on
+        */
+       public WebLibrary(String key, String host, int port) {
+               int index = -1;
+               if (key != null) {
+                       index = key.indexOf('|');
+               }
+
+               if (index >= 0) {
+                       this.key = key.substring(0, index);
+                       this.subkey = key.substring(index + 1);
+               } else {
+                       this.key = key;
+                       this.subkey = "";
+               }
+
+               this.rw = subkey.contains("|rw");
+
+               this.host = host;
+               this.port = port;
+
+               // TODO: not supported yet
+               this.rw = false;
+       }
+
+       @Override
+       public Status getStatus() {
+               try {
+                       download("/");
+               } catch (IOException e) {
+                       try {
+                               download("/style.css");
+                               return Status.UNAUTHORIZED;
+                       } catch (IOException ioe) {
+                               return Status.INVALID;
+                       }
+               }
+
+               return rw ? Status.READ_WRITE : Status.READ_ONLY;
+       }
+
+       @Override
+       public String getLibraryName() {
+               return (rw ? "[READ-ONLY] " : "") + host + ":" + port;
+       }
+
+       @Override
+       public Image getCover(String luid) throws IOException {
+               InputStream in = download("/story/" + luid + "/cover");
+               if (in != null) {
+                       return new Image(in);
+               }
+
+               return null;
+       }
+
+       @Override
+       public Image getCustomSourceCover(final String source) throws IOException {
+               // TODO maybe global system in BasicLib ?
+               return null;
+       }
+
+       @Override
+       public Image getCustomAuthorCover(final String author) throws IOException {
+               // TODO maybe global system in BasicLib ?
+               return null;
+       }
+
+       @Override
+       public void setSourceCover(String source, String luid) throws IOException {
+               // TODO Auto-generated method stub
+               throw new IOException("Not implemented yet");
+       }
+
+       @Override
+       public void setAuthorCover(String author, String luid) throws IOException {
+               // TODO Auto-generated method stub
+               throw new IOException("Not implemented yet");
+       }
+
+       @Override
+       public synchronized Story getStory(final String luid, Progress pg)
+                       throws IOException {
+
+               // TODO: pg
+
+               Story story;
+               InputStream in = download("/story/" + luid + "/json");
+               try {
+                       JSONObject json = new JSONObject(IOUtils.readSmallStream(in));
+                       story = JsonIO.toStory(json);
+               } finally {
+                       in.close();
+               }
+
+               story.getMeta().setCover(getCover(luid));
+               int chapNum = 1;
+               for (Chapter chap : story) {
+                       int number = 1;
+                       for (Paragraph para : chap) {
+                               if (para.getType() == ParagraphType.IMAGE) {
+                                       InputStream subin = download(
+                                                       "/story/" + luid + "/" + chapNum + "/" + number);
+                                       try {
+                                               para.setContentImage(new Image(subin));
+                                       } finally {
+                                               subin.close();
+                                       }
+                               }
+
+                               number++;
+                       }
+
+                       chapNum++;
+               }
+
+               return story;
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+               InputStream in = download("/list/luids");
+               JSONArray jsonArr = new JSONArray(IOUtils.readSmallStream(in));
+               for (int i = 0; i < jsonArr.length(); i++) {
+                       JSONObject json = jsonArr.getJSONObject(i);
+                       metas.add(JsonIO.toMetaData(json));
+               }
+
+               return metas;
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       public MetaData imprt(final URL url, Progress pg) throws IOException {
+               if (true)
+                       throw new IOException("Not implemented yet");
+
+               // Import the file locally if it is actually a file
+
+               if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
+                       return super.imprt(url, pg);
+               }
+
+               // Import it remotely if it is an URL
+
+               // TODO
+               return super.imprt(url, pg);
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       protected synchronized void changeSTA(final String luid,
+                       final String newSource, final String newTitle,
+                       final String newAuthor, Progress pg) throws IOException {
+               // TODO
+               super.changeSTA(luid, newSource, newTitle, newAuthor, pg);
+       }
+
+       @Override
+       protected void updateInfo(MetaData meta) {
+               // Will be taken care of directly server side
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               // Will be taken care of directly server side
+       }
+
+       // The following methods are only used by Save and Delete in BasicLibrary:
+
+       @Override
+       protected int getNextId() {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       //
+
+       @Override
+       public File getFile(final String luid, Progress pg) {
+               throw new java.lang.InternalError(
+                               "Operation not supportorted on remote Libraries");
+       }
+
+       // starts with "/"
+       private InputStream download(String path) throws IOException {
+               URL url = new URL(host + ":" + port + path);
+
+               Map<String, String> post = new HashMap<String, String>();
+               post.put("login", subkey);
+               post.put("password", key);
+
+               return Instance.getInstance().getCache().openNoCache(url, null, post,
+                               null, null);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/WebLibraryServer.java b/src/be/nikiroo/fanfix/library/WebLibraryServer.java
new file mode 100644 (file)
index 0000000..3e2c906
--- /dev/null
@@ -0,0 +1,1102 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.KeyStore;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLServerSocketFactory;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.JsonIO;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.web.WebLibraryServerIndex;
+import be.nikiroo.fanfix.reader.TextOutput;
+import be.nikiroo.utils.CookieUtils;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.NanoHTTPD;
+import be.nikiroo.utils.NanoHTTPD.IHTTPSession;
+import be.nikiroo.utils.NanoHTTPD.Response;
+import be.nikiroo.utils.NanoHTTPD.Response.Status;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.Version;
+
+public class WebLibraryServer implements Runnable {
+       static private String VIEWER_URL_BASE = "/view/story/";
+       static private String VIEWER_URL = VIEWER_URL_BASE + "{luid}/{chap}/{para}";
+       static private String STORY_URL_BASE = "/story/";
+       static private String STORY_URL = STORY_URL_BASE + "{luid}/{chap}/{para}";
+       static private String STORY_URL_COVER = STORY_URL_BASE + "{luid}/cover";
+       static private String LIST_URL = "/list/";
+
+       private class LoginResult {
+               private boolean success;
+               private boolean rw;
+               private boolean wl;
+               private String wookie;
+               private String token;
+               private boolean badLogin;
+               private boolean badToken;
+
+               public LoginResult(String who, String key, String subkey,
+                               boolean success, boolean rw, boolean wl) {
+                       this.success = success;
+                       this.rw = rw;
+                       this.wl = wl;
+                       this.wookie = CookieUtils.generateCookie(who + key, 0);
+
+                       String opts = "";
+                       if (rw)
+                               opts += "|rw";
+                       if (!wl)
+                               opts += "|wl";
+
+                       this.token = wookie + "~"
+                                       + CookieUtils.generateCookie(wookie + subkey + opts, 0)
+                                       + "~" + opts;
+                       this.badLogin = !success;
+               }
+
+               public LoginResult(String token, String who, String key,
+                               List<String> subkeys) {
+
+                       if (token != null) {
+                               String hashes[] = token.split("~");
+                               if (hashes.length >= 2) {
+                                       String wookie = hashes[0];
+                                       String rehashed = hashes[1];
+                                       String opts = hashes.length > 2 ? hashes[2] : "";
+
+                                       if (CookieUtils.validateCookie(who + key, wookie)) {
+                                               if (subkeys == null) {
+                                                       subkeys = new ArrayList<String>();
+                                               }
+                                               subkeys = new ArrayList<String>(subkeys);
+                                               subkeys.add("");
+
+                                               for (String subkey : subkeys) {
+                                                       if (CookieUtils.validateCookie(
+                                                                       wookie + subkey + opts, rehashed)) {
+                                                               this.wookie = wookie;
+                                                               this.token = token;
+                                                               this.success = true;
+
+                                                               this.rw = opts.contains("|rw");
+                                                               this.wl = !opts.contains("|wl");
+                                                       }
+                                               }
+                                       }
+                               }
+
+                               this.badToken = !success;
+                       }
+
+                       // No token -> no bad token
+               }
+
+               public boolean isSuccess() {
+                       return success;
+               }
+
+               public boolean isRw() {
+                       return rw;
+               }
+
+               public boolean isWl() {
+                       return wl;
+               }
+
+               public String getToken() {
+                       return token;
+               }
+
+               public boolean isBadLogin() {
+                       return badLogin;
+               }
+
+               public boolean isBadToken() {
+                       return badToken;
+               }
+       }
+
+       private NanoHTTPD server;
+       private Map<String, Story> storyCache = new HashMap<String, Story>();
+       private LinkedList<String> storyCacheOrder = new LinkedList<String>();
+       private long storyCacheSize = 0;
+       private long maxStoryCacheSize;
+       private TraceHandler tracer = new TraceHandler();
+
+       public WebLibraryServer(boolean secure) throws IOException {
+               Integer port = Instance.getInstance().getConfig()
+                               .getInteger(Config.SERVER_PORT);
+               if (port == null) {
+                       throw new IOException(
+                                       "Cannot start web server: port not specified");
+               }
+
+               int cacheMb = Instance.getInstance().getConfig()
+                               .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
+               maxStoryCacheSize = cacheMb * 1024 * 1024;
+
+               setTraceHandler(Instance.getInstance().getTraceHandler());
+
+               SSLServerSocketFactory ssf = null;
+               if (secure) {
+                       String keystorePath = Instance.getInstance().getConfig()
+                                       .getString(Config.SERVER_SSL_KEYSTORE, "");
+                       String keystorePass = Instance.getInstance().getConfig()
+                                       .getString(Config.SERVER_SSL_KEYSTORE_PASS);
+
+                       if (secure && keystorePath.isEmpty()) {
+                               throw new IOException(
+                                               "Cannot start a secure web server: no keystore.jks file povided");
+                       }
+
+                       if (!keystorePath.isEmpty()) {
+                               File keystoreFile = new File(keystorePath);
+                               try {
+                                       KeyStore keystore = KeyStore
+                                                       .getInstance(KeyStore.getDefaultType());
+                                       InputStream keystoreStream = new FileInputStream(
+                                                       keystoreFile);
+                                       try {
+                                               keystore.load(keystoreStream,
+                                                               keystorePass.toCharArray());
+                                               KeyManagerFactory keyManagerFactory = KeyManagerFactory
+                                                               .getInstance(KeyManagerFactory
+                                                                               .getDefaultAlgorithm());
+                                               keyManagerFactory.init(keystore,
+                                                               keystorePass.toCharArray());
+                                               ssf = NanoHTTPD.makeSSLSocketFactory(keystore,
+                                                               keyManagerFactory);
+                                       } finally {
+                                               keystoreStream.close();
+                                       }
+                               } catch (Exception e) {
+                                       throw new IOException(e.getMessage());
+                               }
+                       }
+               }
+
+               server = new NanoHTTPD(port) {
+                       @Override
+                       public Response serve(final IHTTPSession session) {
+                               super.serve(session);
+
+                               String query = session.getQueryParameterString(); // a=a%20b&dd=2
+                               Method method = session.getMethod(); // GET, POST..
+                               String uri = session.getUri(); // /home.html
+
+                               // need them in real time (not just those sent by the UA)
+                               Map<String, String> cookies = new HashMap<String, String>();
+                               for (String cookie : session.getCookies()) {
+                                       cookies.put(cookie, session.getCookies().read(cookie));
+                               }
+
+                               List<String> whitelist = Instance.getInstance().getConfig()
+                                               .getList(Config.SERVER_WHITELIST);
+                               if (whitelist == null) {
+                                       whitelist = new ArrayList<String>();
+                               }
+
+                               LoginResult login = null;
+                               Map<String, String> params = session.getParms();
+                               String who = session.getRemoteHostName()
+                                               + session.getRemoteIpAddress();
+                               if (params.get("login") != null) {
+                                       login = login(who, params.get("password"),
+                                                       params.get("login"), whitelist);
+                               } else {
+                                       String token = cookies.get("token");
+                                       login = login(who, token, Instance.getInstance().getConfig()
+                                                       .getList(Config.SERVER_ALLOWED_SUBKEYS));
+                               }
+
+                               if (login.isSuccess()) {
+                                       if (!login.isWl()) {
+                                               whitelist.clear();
+                                       }
+
+                                       // refresh token
+                                       session.getCookies().set(new Cookie("token",
+                                                       login.getToken(), "30; path=/"));
+
+                                       // set options
+                                       String optionName = params.get("optionName");
+                                       if (optionName != null && !optionName.isEmpty()) {
+                                               String optionValue = params.get("optionValue");
+                                               if (optionValue == null || optionValue.isEmpty()) {
+                                                       session.getCookies().delete(optionName);
+                                                       cookies.remove(optionName);
+                                               } else {
+                                                       session.getCookies().set(new Cookie(optionName,
+                                                                       optionValue, "; path=/"));
+                                                       cookies.put(optionName, optionValue);
+                                               }
+                                       }
+                               }
+
+                               Response rep = null;
+                               if (!login.isSuccess() && (uri.equals("/") //
+                                               || uri.startsWith(STORY_URL_BASE) //
+                                               || uri.startsWith(VIEWER_URL_BASE) //
+                                               || uri.startsWith(LIST_URL))) {
+                                       rep = loginPage(login, uri);
+                               }
+
+                               if (rep == null) {
+                                       try {
+                                               if (uri.equals("/")) {
+                                                       rep = root(session, cookies, whitelist);
+                                               } else if (uri.startsWith(LIST_URL)) {
+                                                       rep = getList(uri, whitelist);
+                                               } else if (uri.startsWith(STORY_URL_BASE)) {
+                                                       rep = getStoryPart(uri, whitelist);
+                                               } else if (uri.startsWith(VIEWER_URL_BASE)) {
+                                                       rep = getViewer(cookies, uri, whitelist);
+                                               } else if (uri.equals("/logout")) {
+                                                       session.getCookies().delete("token");
+                                                       cookies.remove("token");
+                                                       rep = loginPage(login, uri);
+                                               } else {
+                                                       if (uri.startsWith("/"))
+                                                               uri = uri.substring(1);
+                                                       InputStream in = IOUtils.openResource(
+                                                                       WebLibraryServerIndex.class, uri);
+                                                       if (in != null) {
+                                                               String mimeType = MIME_PLAINTEXT;
+                                                               if (uri.endsWith(".css")) {
+                                                                       mimeType = "text/css";
+                                                               } else if (uri.endsWith(".html")) {
+                                                                       mimeType = "text/html";
+                                                               } else if (uri.endsWith(".js")) {
+                                                                       mimeType = "text/javascript";
+                                                               }
+                                                               rep = newChunkedResponse(Status.OK, mimeType,
+                                                                               in);
+                                                       } else {
+                                                               getTraceHandler().trace("404: " + uri);
+                                                       }
+                                               }
+
+                                               if (rep == null) {
+                                                       rep = newFixedLengthResponse(Status.NOT_FOUND,
+                                                                       NanoHTTPD.MIME_PLAINTEXT, "Not Found");
+                                               }
+                                       } catch (Exception e) {
+                                               Instance.getInstance().getTraceHandler().error(
+                                                               new IOException("Cannot process web request",
+                                                                               e));
+                                               rep = newFixedLengthResponse(Status.INTERNAL_ERROR,
+                                                               NanoHTTPD.MIME_PLAINTEXT, "An error occured");
+                                       }
+                               }
+
+                               return rep;
+
+                               // Get status: for story, use "luid" + active map of current
+                               // luids
+                               // map must use a addRef/removeRef and delete at 0
+
+                               // http://localhost:2000/?token=ok
+
+                               //
+                               // MetaData meta = new MetaData();
+                               // meta.setTitle("Title");
+                               // meta.setLuid("000");
+                               //
+                               // JSONObject json = new JSONObject();
+                               // json.put("", MetaData.class.getName());
+                               // json.put("title", meta.getTitle());
+                               // json.put("luid", meta.getLuid());
+                               //
+                               // return newFixedLengthResponse(json.toString());
+                       }
+               };
+
+               if (ssf != null) {
+                       getTraceHandler().trace("Install SSL on the web server...");
+                       server.makeSecure(ssf, null);
+                       getTraceHandler().trace("Done.");
+               }
+       }
+
+       @Override
+       public void run() {
+               try {
+                       server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
+               } catch (IOException e) {
+                       tracer.error(new IOException("Cannot start the web server", e));
+               }
+       }
+
+       /**
+        * Start the server (listen on the network for new connections).
+        * <p>
+        * Can only be called once.
+        * <p>
+        * This call is asynchronous, and will just start a new {@link Thread} on
+        * itself (see {@link WebLibraryServer#run()}).
+        */
+       public void start() {
+               new Thread(this).start();
+       }
+
+       /**
+        * The traces handler for this {@link WebLibraryServer}.
+        * 
+        * @return the traces handler
+        */
+       public TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link WebLibraryServer}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               this.tracer = tracer;
+       }
+
+       private LoginResult login(String who, String token, List<String> subkeys) {
+               String realKey = Instance.getInstance().getConfig()
+                               .getString(Config.SERVER_KEY);
+               realKey = realKey == null ? "" : realKey;
+               return new LoginResult(token, who, realKey, subkeys);
+       }
+
+       // allow rw/wl
+       private LoginResult login(String who, String key, String subkey,
+                       List<String> whitelist) {
+               String realKey = Instance.getInstance().getConfig()
+                               .getString(Config.SERVER_KEY);
+
+               // I don't like NULLs...
+               realKey = realKey == null ? "" : realKey;
+               key = key == null ? "" : key;
+               subkey = subkey == null ? "" : subkey;
+
+               if (!realKey.equals(key)) {
+                       return new LoginResult(null, null, null, false, false, false);
+               }
+
+               // defaults are positive (as previous versions without the feature)
+               boolean rw = true;
+               boolean wl = true;
+
+               if (whitelist.isEmpty()) {
+                       wl = false;
+               }
+
+               rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
+                               rw);
+               if (!subkey.isEmpty()) {
+                       List<String> allowed = Instance.getInstance().getConfig()
+                                       .getList(Config.SERVER_ALLOWED_SUBKEYS);
+                       if (allowed != null && allowed.contains(subkey)) {
+                               if ((subkey + "|").contains("|rw|")) {
+                                       rw = true;
+                               }
+                               if ((subkey + "|").contains("|wl|")) {
+                                       wl = false; // |wl| = bypass whitelist
+                               }
+                       } else {
+                               return new LoginResult(null, null, null, false, false, false);
+                       }
+               }
+
+               return new LoginResult(who, key, subkey, true, rw, wl);
+       }
+
+       private Response loginPage(LoginResult login, String uri) {
+               StringBuilder builder = new StringBuilder();
+
+               appendPreHtml(builder, true);
+
+               if (login.isBadLogin()) {
+                       builder.append("<div class='error'>Bad login or password</div>");
+               } else if (login.isBadToken()) {
+                       builder.append("<div class='error'>Your session timed out</div>");
+               }
+
+               if (uri.equals("/logout")) {
+                       uri = "/";
+               }
+
+               builder.append(
+                               "<form method='POST' action='" + uri + "' class='login'>\n");
+               builder.append(
+                               "<p>You must be logged into the system to see the stories.</p>");
+               builder.append("\t<input type='text' name='login' />\n");
+               builder.append("\t<input type='password' name='password' />\n");
+               builder.append("\t<input type='submit' value='Login' />\n");
+               builder.append("</form>\n");
+
+               appendPostHtml(builder);
+
+               return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
+                               NanoHTTPD.MIME_HTML, builder.toString());
+       }
+
+       protected Response getList(String uri, List<String> whitelist)
+                       throws IOException {
+               if (uri.equals("/list/luids")) {
+                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                       List<MetaData> metas = lib.getList().filter(whitelist, null, null);
+                       List<JSONObject> jsons = new ArrayList<JSONObject>();
+                       for (MetaData meta : metas) {
+                               jsons.add(JsonIO.toJson(meta));
+                       }
+
+                       return newInputStreamResponse("application/json",
+                                       new ByteArrayInputStream(
+                                                       new JSONArray(jsons).toString().getBytes()));
+               }
+
+               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                               NanoHTTPD.MIME_PLAINTEXT, null);
+       }
+
+       private Response root(IHTTPSession session, Map<String, String> cookies,
+                       List<String> whitelist) throws IOException {
+               BasicLibrary lib = Instance.getInstance().getLibrary();
+               MetaResultList result = lib.getList();
+               result = new MetaResultList(result.filter(whitelist, null, null));
+               StringBuilder builder = new StringBuilder();
+
+               appendPreHtml(builder, true);
+
+               String filter = cookies.get("filter");
+               if (filter == null) {
+                       filter = "";
+               }
+
+               Map<String, String> params = session.getParms();
+               String browser = params.get("browser") == null ? ""
+                               : params.get("browser");
+               String browser2 = params.get("browser2") == null ? ""
+                               : params.get("browser2");
+               String browser3 = params.get("browser3") == null ? ""
+                               : params.get("browser3");
+
+               String filterSource = null;
+               String filterAuthor = null;
+               String filterTag = null;
+
+               // TODO: javascript in realtime, using visible=false + hide [submit]
+
+               builder.append("<form class='browser'>\n");
+               builder.append("<div class='breadcrumbs'>\n");
+
+               builder.append("\t<select name='browser'>");
+               appendOption(builder, 2, "", "", browser);
+               appendOption(builder, 2, "Sources", "sources", browser);
+               appendOption(builder, 2, "Authors", "authors", browser);
+               appendOption(builder, 2, "Tags", "tags", browser);
+               builder.append("\t</select>\n");
+
+               if (!browser.isEmpty()) {
+                       builder.append("\t<select name='browser2'>");
+                       if (browser.equals("sources")) {
+                               filterSource = browser2.isEmpty() ? filterSource : browser2;
+                               // TODO: if 1 group -> no group
+                               appendOption(builder, 2, "", "", browser2);
+                               Map<String, List<String>> sources = result.getSourcesGrouped();
+                               for (String source : sources.keySet()) {
+                                       appendOption(builder, 2, source, source, browser2);
+                               }
+                       } else if (browser.equals("authors")) {
+                               filterAuthor = browser2.isEmpty() ? filterAuthor : browser2;
+                               // TODO: if 1 group -> no group
+                               appendOption(builder, 2, "", "", browser2);
+                               Map<String, List<String>> authors = result.getAuthorsGrouped();
+                               for (String author : authors.keySet()) {
+                                       appendOption(builder, 2, author, author, browser2);
+                               }
+                       } else if (browser.equals("tags")) {
+                               filterTag = browser2.isEmpty() ? filterTag : browser2;
+                               appendOption(builder, 2, "", "", browser2);
+                               for (String tag : result.getTags()) {
+                                       appendOption(builder, 2, tag, tag, browser2);
+                               }
+                       }
+                       builder.append("\t</select>\n");
+               }
+
+               if (!browser2.isEmpty()) {
+                       if (browser.equals("sources")) {
+                               filterSource = browser3.isEmpty() ? filterSource : browser3;
+                               Map<String, List<String>> sourcesGrouped = result
+                                               .getSourcesGrouped();
+                               List<String> sources = sourcesGrouped.get(browser2);
+                               if (sources != null && !sources.isEmpty()) {
+                                       // TODO: single empty value
+                                       builder.append("\t<select name='browser3'>");
+                                       appendOption(builder, 2, "", "", browser3);
+                                       for (String source : sources) {
+                                               appendOption(builder, 2, source, source, browser3);
+                                       }
+                                       builder.append("\t</select>\n");
+                               }
+                       } else if (browser.equals("authors")) {
+                               filterAuthor = browser3.isEmpty() ? filterAuthor : browser3;
+                               Map<String, List<String>> authorsGrouped = result
+                                               .getAuthorsGrouped();
+                               List<String> authors = authorsGrouped.get(browser2);
+                               if (authors != null && !authors.isEmpty()) {
+                                       // TODO: single empty value
+                                       builder.append("\t<select name='browser3'>");
+                                       appendOption(builder, 2, "", "", browser3);
+                                       for (String author : authors) {
+                                               appendOption(builder, 2, author, author, browser3);
+                                       }
+                                       builder.append("\t</select>\n");
+                               }
+                       }
+               }
+
+               builder.append("\t<input type='submit' value='Select'/>\n");
+               builder.append("</div>\n");
+
+               // TODO: javascript in realtime, using visible=false + hide [submit]
+               builder.append("<div class='filter'>\n");
+               builder.append("\tFilter: \n");
+               builder.append(
+                               "\t<input name='optionName'  type='hidden' value='filter' />\n");
+               builder.append("\t<input name='optionValue' type='text'   value='"
+                               + filter + "' place-holder='...' />\n");
+               builder.append(
+                               "\t<input name='submit' type='submit' value='Filter' />\n");
+               builder.append("</div>\n");
+               builder.append("</form>\n");
+
+               builder.append("\t<div class='books'>");
+               for (MetaData meta : result.getMetas()) {
+                       if (!filter.isEmpty() && !meta.getTitle().toLowerCase()
+                                       .contains(filter.toLowerCase())) {
+                               continue;
+                       }
+
+                       // TODO Sub sources
+                       if (filterSource != null
+                                       && !filterSource.equals(meta.getSource())) {
+                               continue;
+                       }
+
+                       // TODO: sub authors
+                       if (filterAuthor != null
+                                       && !filterAuthor.equals(meta.getAuthor())) {
+                               continue;
+                       }
+
+                       if (filterTag != null && !meta.getTags().contains(filterTag)) {
+                               continue;
+                       }
+
+                       builder.append("<div class='book_line'>");
+                       builder.append("<a href='");
+                       builder.append(getViewUrl(meta.getLuid(), 0, null));
+                       builder.append("'");
+                       builder.append(" class='link'>");
+
+                       if (lib.isCached(meta.getLuid())) {
+                               // ◉ = &#9673;
+                               builder.append(
+                                               "<span class='cache_icon cached'>&#9673;</span>");
+                       } else {
+                               // ○ = &#9675;
+                               builder.append(
+                                               "<span class='cache_icon uncached'>&#9675;</span>");
+                       }
+                       builder.append("<span class='luid'>");
+                       builder.append(meta.getLuid());
+                       builder.append("</span>");
+                       builder.append("<span class='title'>");
+                       builder.append(meta.getTitle());
+                       builder.append("</span>");
+                       builder.append("<span class='author'>");
+                       if (meta.getAuthor() != null && !meta.getAuthor().isEmpty()) {
+                               builder.append("(").append(meta.getAuthor()).append(")");
+                       }
+                       builder.append("</span>");
+                       builder.append("</a></div>\n");
+               }
+               builder.append("</div>");
+
+               appendPostHtml(builder);
+               return NanoHTTPD.newFixedLengthResponse(builder.toString());
+       }
+
+       // /story/luid/chapter/para <-- text/image
+       // /story/luid/cover <-- image
+       // /story/luid/metadata <-- json
+       // /story/luid/json <-- json, whole chapter (no images)
+       private Response getStoryPart(String uri, List<String> whitelist) {
+               String[] cover = uri.split("/");
+               int off = 2;
+
+               if (cover.length < off + 2) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                       NanoHTTPD.MIME_PLAINTEXT, null);
+               }
+
+               String luid = cover[off + 0];
+               String chapterStr = cover[off + 1];
+               String imageStr = cover.length < off + 3 ? null : cover[off + 2];
+
+               // 1-based (0 = desc)
+               int chapter = 0;
+               if (chapterStr != null && !"cover".equals(chapterStr)
+                               && !"metadata".equals(chapterStr) && !"json".equals(chapterStr)) {
+                       try {
+                               chapter = Integer.parseInt(chapterStr);
+                               if (chapter < 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
+                       }
+               }
+
+               // 1-based
+               int paragraph = 1;
+               if (imageStr != null) {
+                       try {
+                               paragraph = Integer.parseInt(imageStr);
+                               if (paragraph < 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
+                       }
+               }
+
+               String mimeType = NanoHTTPD.MIME_PLAINTEXT;
+               InputStream in = null;
+               try {
+                       if ("cover".equals(chapterStr)) {
+                               Image img = getCover(luid, whitelist);
+                               if (img != null) {
+                                       in = img.newInputStream();
+                               }
+                       } else if ("metadata".equals(chapterStr)) {
+                               MetaData meta = meta(luid, whitelist);
+                               JSONObject json = JsonIO.toJson(meta);
+                               mimeType = "application/json";
+                               in = new ByteArrayInputStream(json.toString().getBytes());
+                       }  else if ("json".equals(chapterStr)) {
+                               Story story = story(luid, whitelist);
+                               JSONObject json = JsonIO.toJson(story);
+                               mimeType = "application/json";
+                               in = new ByteArrayInputStream(json.toString().getBytes());
+                       } else {
+                               Story story = story(luid, whitelist);
+                               if (story != null) {
+                                       if (chapter == 0) {
+                                               StringBuilder builder = new StringBuilder();
+                                               for (Paragraph p : story.getMeta().getResume()) {
+                                                       if (builder.length() == 0) {
+                                                               builder.append("\n");
+                                                       }
+                                                       builder.append(p.getContent());
+                                               }
+
+                                               in = new ByteArrayInputStream(
+                                                               builder.toString().getBytes("utf-8"));
+                                       } else {
+                                               Paragraph para = story.getChapters().get(chapter - 1)
+                                                               .getParagraphs().get(paragraph - 1);
+                                               Image img = para.getContentImage();
+                                               if (para.getType() == ParagraphType.IMAGE) {
+                                                       // TODO: get correct image type
+                                                       mimeType = "image/png";
+                                                       in = img.newInputStream();
+                                               } else {
+                                                       in = new ByteArrayInputStream(
+                                                                       para.getContent().getBytes("utf-8"));
+                                               }
+                                       }
+                               }
+                       }
+               } catch (IndexOutOfBoundsException e) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+                                       NanoHTTPD.MIME_PLAINTEXT,
+                                       "Chapter or paragraph does not exist");
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler()
+                                       .error(new IOException("Cannot get image: " + uri, e));
+                       return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
+                                       NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
+               }
+
+               return newInputStreamResponse(mimeType, in);
+       }
+
+       private Response getViewer(Map<String, String> cookies, String uri,
+                       List<String> whitelist) {
+               String[] cover = uri.split("/");
+               int off = 2;
+
+               if (cover.length < off + 2) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                       NanoHTTPD.MIME_PLAINTEXT, null);
+               }
+
+               String type = cover[off + 0];
+               String luid = cover[off + 1];
+               String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
+               String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
+
+               // 1-based (0 = desc)
+               int chapter = -1;
+               if (chapterStr != null) {
+                       try {
+                               chapter = Integer.parseInt(chapterStr);
+                               if (chapter < 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
+                       }
+               }
+
+               // 1-based
+               int paragraph = 0;
+               if (paragraphStr != null) {
+                       try {
+                               paragraph = Integer.parseInt(paragraphStr);
+                               if (paragraph <= 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
+                       }
+               }
+
+               try {
+                       Story story = story(luid, whitelist);
+                       if (story == null) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Story not found");
+                       }
+
+                       StringBuilder builder = new StringBuilder();
+                       appendPreHtml(builder, false);
+
+                       if (chapter < 0) {
+                               builder.append(story);
+                       } else {
+                               if (chapter == 0) {
+                                       // TODO: description
+                                       chapter = 1;
+                               }
+
+                               Chapter chap = null;
+                               try {
+                                       chap = story.getChapters().get(chapter - 1);
+                               } catch (IndexOutOfBoundsException e) {
+                                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+                                                       NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
+                               }
+
+                               if (story.getMeta().isImageDocument() && paragraph <= 0) {
+                                       paragraph = 1;
+                               }
+
+                               String first, previous, next, last;
+                               String content;
+
+                               if (paragraph <= 0) {
+                                       first = getViewUrl(luid, 1, null);
+                                       previous = getViewUrl(luid, (Math.max(chapter - 1, 1)),
+                                                       null);
+                                       next = getViewUrl(luid,
+                                                       (Math.min(chapter + 1, story.getChapters().size())),
+                                                       null);
+                                       last = getViewUrl(luid, story.getChapters().size(), null);
+
+                                       content = "<div class='viewer text'>\n"
+                                                       + new TextOutput(false).convert(chap, true)
+                                                       + "</div>\n";
+                               } else {
+                                       first = getViewUrl(luid, chapter, 1);
+                                       previous = getViewUrl(luid, chapter,
+                                                       (Math.max(paragraph - 1, 1)));
+                                       next = getViewUrl(luid, chapter, (Math.min(paragraph + 1,
+                                                       chap.getParagraphs().size())));
+                                       last = getViewUrl(luid, chapter,
+                                                       chap.getParagraphs().size());
+
+                                       Paragraph para = null;
+                                       try {
+                                               para = chap.getParagraphs().get(paragraph - 1);
+                                       } catch (IndexOutOfBoundsException e) {
+                                               return NanoHTTPD.newFixedLengthResponse(
+                                                               Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+                                                               "Paragraph not found");
+                                       }
+
+                                       if (para.getType() == ParagraphType.IMAGE) {
+                                               String zoomStyle = "max-width: 100%;";
+                                               String zoomOption = cookies.get("zoom");
+                                               if (zoomOption != null && !zoomOption.isEmpty()) {
+                                                       if (zoomOption.equals("real")) {
+                                                               zoomStyle = "";
+                                                       } else if (zoomOption.equals("width")) {
+                                                               zoomStyle = "max-width: 100%;";
+                                                       } else if (zoomOption.equals("height")) {
+                                                               // see height of navbar + optionbar
+                                                               zoomStyle = "max-height: calc(100% - 128px);";
+                                                       }
+                                               }
+                                               content = String.format("" //
+                                                               + "<a class='viewer link' href='%s'>" //
+                                                               + "<img class='viewer img' style='%s' src='%s'/>"
+                                                               + "</a>", //
+                                                               next, //
+                                                               zoomStyle, //
+                                                               getStoryUrl(luid, chapter, paragraph));
+                                       } else {
+                                               content = para.getContent();
+                                       }
+
+                               }
+
+                               builder.append(String.format("" //
+                                               + "<div class='bar navbar'>\n" //
+                                               + "\t<a class='button first' href='%s'>&lt;&lt;</a>\n"//
+                                               + "\t<a class='button previous' href='%s'>&lt;</a>\n"//
+                                               + "\t<a class='button next' href='%s'>&gt;</a>\n"//
+                                               + "\t<a class='button last' href='%s'>&gt;&gt;</a>\n"//
+                                               + "</div>\n" //
+                                               + "%s", //
+                                               first, //
+                                               previous, //
+                                               next, //
+                                               last, //
+                                               content //
+                               ));
+
+                               builder.append("<div class='bar optionbar ");
+                               if (paragraph > 0) {
+                                       builder.append("s4");
+                               } else {
+                                       builder.append("s1");
+                               }
+                               builder.append("'>\n");
+                               builder.append(
+                                               "       <a class='button back' href='/'>BACK</a>\n");
+
+                               if (paragraph > 0) {
+                                       builder.append(String.format("" //
+                                                       + "\t<a class='button zoomreal'   href='%s'>REAL</a>\n"//
+                                                       + "\t<a class='button zoomwidth'  href='%s'>WIDTH</a>\n"//
+                                                       + "\t<a class='button zoomheight' href='%s'>HEIGHT</a>\n"//
+                                                       + "</div>\n", //
+                                                       uri + "?optionName=zoom&optionValue=real", //
+                                                       uri + "?optionName=zoom&optionValue=width", //
+                                                       uri + "?optionName=zoom&optionValue=height" //
+                                       ));
+                               }
+                       }
+
+                       appendPostHtml(builder);
+                       return NanoHTTPD.newFixedLengthResponse(Status.OK,
+                                       NanoHTTPD.MIME_HTML, builder.toString());
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler()
+                                       .error(new IOException("Cannot get image: " + uri, e));
+                       return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
+                                       NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
+               }
+       }
+
+       private Response newInputStreamResponse(String mimeType, InputStream in) {
+               if (in == null) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
+                                       null);
+               }
+               return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
+       }
+
+       private String getContentOf(String file) {
+               InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
+                               file);
+               if (in != null) {
+                       try {
+                               return IOUtils.readSmallStream(in);
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(
+                                               new IOException("Cannot get file: index.pre.html", e));
+                       }
+               }
+
+               return "";
+       }
+
+       private String getViewUrl(String luid, int chap, Integer para) {
+               return VIEWER_URL //
+                               .replace("{luid}", luid) //
+                               .replace("{chap}", Integer.toString(chap)) //
+                               .replace("/{para}",
+                                               para == null ? "" : "/" + Integer.toString(para));
+       }
+
+       private String getStoryUrl(String luid, int chap, Integer para) {
+               return STORY_URL //
+                               .replace("{luid}", luid) //
+                               .replace("{chap}", Integer.toString(chap)) //
+                               .replace("{para}", para == null ? "" : Integer.toString(para));
+       }
+
+       private String getStoryUrlCover(String luid) {
+               return STORY_URL_COVER //
+                               .replace("{luid}", luid);
+       }
+
+       private MetaData meta(String luid, List<String> whitelist)
+                       throws IOException {
+               BasicLibrary lib = Instance.getInstance().getLibrary();
+               MetaData meta = lib.getInfo(luid);
+               if (!whitelist.isEmpty() && !whitelist.contains(meta.getSource())) {
+                       return null;
+               }
+
+               return meta;
+       }
+
+       private Image getCover(String luid, List<String> whitelist)
+                       throws IOException {
+               MetaData meta = meta(luid, whitelist);
+               if (meta != null) {
+                       return meta.getCover();
+               }
+
+               return null;
+       }
+
+       // NULL if not whitelist OK or if not found
+       private Story story(String luid, List<String> whitelist)
+                       throws IOException {
+               synchronized (storyCache) {
+                       if (storyCache.containsKey(luid)) {
+                               Story story = storyCache.get(luid);
+                               if (!whitelist.isEmpty()
+                                               && !whitelist.contains(story.getMeta().getSource())) {
+                                       return null;
+                               }
+
+                               return story;
+                       }
+               }
+
+               Story story = null;
+               MetaData meta = meta(luid, whitelist);
+               if (meta != null) {
+                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                       story = lib.getStory(luid, null);
+                       long size = sizeOf(story);
+
+                       synchronized (storyCache) {
+                               // Could have been added by another request
+                               if (!storyCache.containsKey(luid)) {
+                                       while (!storyCacheOrder.isEmpty()
+                                                       && storyCacheSize + size > maxStoryCacheSize) {
+                                               String oldestLuid = storyCacheOrder.removeFirst();
+                                               Story oldestStory = storyCache.remove(oldestLuid);
+                                               maxStoryCacheSize -= sizeOf(oldestStory);
+                                       }
+
+                                       storyCacheOrder.add(luid);
+                                       storyCache.put(luid, story);
+                               }
+                       }
+               }
+
+               return story;
+       }
+
+       private long sizeOf(Story story) {
+               long size = 0;
+               for (Chapter chap : story) {
+                       for (Paragraph para : chap) {
+                               if (para.getType() == ParagraphType.IMAGE) {
+                                       size += para.getContentImage().getSize();
+                               } else {
+                                       size += para.getContent().length();
+                               }
+                       }
+               }
+
+               return size;
+       }
+
+       private void appendPreHtml(StringBuilder builder, boolean banner) {
+               String favicon = "favicon.ico";
+               String icon = Instance.getInstance().getUiConfig()
+                               .getString(UiConfig.PROGRAM_ICON);
+               if (icon != null) {
+                       favicon = "icon_" + icon.replace("-", "_") + ".png";
+               }
+
+               builder.append(
+                               getContentOf("index.pre.html").replace("favicon.ico", favicon));
+
+               if (banner) {
+                       builder.append("<div class='banner'>\n");
+                       builder.append("\t<img class='ico' src='") //
+                                       .append(favicon) //
+                                       .append("'/>\n");
+                       builder.append("\t<h1>Fanfix</h1>\n");
+                       builder.append("\t<h2>") //
+                                       .append(Version.getCurrentVersion()) //
+                                       .append("</h2>\n");
+                       builder.append("</div>\n");
+               }
+       }
+
+       private void appendPostHtml(StringBuilder builder) {
+               builder.append(getContentOf("index.post.html"));
+       }
+
+       private void appendOption(StringBuilder builder, int depth, String name,
+                       String value, String selected) {
+               for (int i = 0; i < depth; i++) {
+                       builder.append("\t");
+               }
+               builder.append("<option value='").append(value).append("'");
+               if (value.equals(selected)) {
+                       builder.append(" selected='selected'");
+               }
+               builder.append(">").append(name).append("</option>\n");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/package-info.java b/src/be/nikiroo/fanfix/library/package-info.java
new file mode 100644 (file)
index 0000000..1bb63ea
--- /dev/null
@@ -0,0 +1,9 @@
+/**
+ * This package offer a Libraries to store stories into.
+ * <p>
+ * It currently has a local library and a remote one, as well as the required 
+ * remote server. 
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.library;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/web/WebLibraryServerIndex.java b/src/be/nikiroo/fanfix/library/web/WebLibraryServerIndex.java
new file mode 100644 (file)
index 0000000..15c371b
--- /dev/null
@@ -0,0 +1,4 @@
+package be.nikiroo.fanfix.library.web;
+
+public class WebLibraryServerIndex {
+}
diff --git a/src/be/nikiroo/fanfix/library/web/actual_size-32x32.png b/src/be/nikiroo/fanfix/library/web/actual_size-32x32.png
new file mode 100644 (file)
index 0000000..0d356b7
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/actual_size-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/actual_size-64x64.png b/src/be/nikiroo/fanfix/library/web/actual_size-64x64.png
new file mode 100644 (file)
index 0000000..474afda
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/actual_size-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png
new file mode 100644 (file)
index 0000000..e163b60
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png
new file mode 100644 (file)
index 0000000..e66dd93
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png
new file mode 100644 (file)
index 0000000..297dcab
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png
new file mode 100644 (file)
index 0000000..52226f6
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png
new file mode 100644 (file)
index 0000000..af83425
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png
new file mode 100644 (file)
index 0000000..44cba9c
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png
new file mode 100644 (file)
index 0000000..2540b62
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png
new file mode 100644 (file)
index 0000000..ab960b3
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/avicon.ico b/src/be/nikiroo/fanfix/library/web/avicon.ico
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/be/nikiroo/fanfix/library/web/back-32x32.png b/src/be/nikiroo/fanfix/library/web/back-32x32.png
new file mode 100644 (file)
index 0000000..46c19dc
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/back-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/back-64x64.png b/src/be/nikiroo/fanfix/library/web/back-64x64.png
new file mode 100644 (file)
index 0000000..d58d004
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/back-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/favicon.ico b/src/be/nikiroo/fanfix/library/web/favicon.ico
new file mode 100644 (file)
index 0000000..feedaf0
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/favicon.ico differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png b/src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png
new file mode 100644 (file)
index 0000000..727dec2
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png b/src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png
new file mode 100644 (file)
index 0000000..cec7da4
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png b/src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png
new file mode 100644 (file)
index 0000000..ee90843
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png b/src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png
new file mode 100644 (file)
index 0000000..7b897d2
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_alternative.png b/src/be/nikiroo/fanfix/library/web/icon_alternative.png
new file mode 100644 (file)
index 0000000..4ab0957
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_alternative.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_default.png b/src/be/nikiroo/fanfix/library/web/icon_default.png
new file mode 100644 (file)
index 0000000..983b344
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_default.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_magic_book.png b/src/be/nikiroo/fanfix/library/web/icon_magic_book.png
new file mode 100644 (file)
index 0000000..1798dd3
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_magic_book.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_pony_book.png b/src/be/nikiroo/fanfix/library/web/icon_pony_book.png
new file mode 100644 (file)
index 0000000..fb6fe0d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_pony_book.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_pony_library.png b/src/be/nikiroo/fanfix/library/web/icon_pony_library.png
new file mode 100644 (file)
index 0000000..a56a4d2
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_pony_library.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/index.post.html b/src/be/nikiroo/fanfix/library/web/index.post.html
new file mode 100644 (file)
index 0000000..d4e0905
--- /dev/null
@@ -0,0 +1,2 @@
+</div>
+</body>
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/web/index.pre.html b/src/be/nikiroo/fanfix/library/web/index.pre.html
new file mode 100644 (file)
index 0000000..18c1508
--- /dev/null
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+<!--
+       Copyright 2020 David ROULET
+       
+       This file is part of fanfix.
+       
+       fanfix is free software: you can redistribute it and/or modify
+       it under the terms of the GNU Affero General Public License as published by
+       the Free Software Foundation, either version 3 of the License, or
+       (at your option) any later version.
+       
+       fanfix is distributed in the hope that it will be useful,
+       but WITHOUT ANY WARRANTY; without even the implied warranty of
+       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+       GNU Affero General Public License for more details.
+       
+       You should have received a copy of the GNU Affero General Public License
+       along with fanfix.  If not, see <https://www.gnu.org/licenses/>.
+       ___________________________________________________________________________
+
+       This website was coded by:
+                       A kangaroo.
+                                                  _  _
+                                                 (\\( \
+                                                  `.\-.)
+                              _...._            _,-"   `-.
+\                           ,"      `-._.- -.,-"       .  \
+ \`.                      ,"                               `.
+  \ `-...__              /                           .   .:  y
+   `._     ``-...__     /                           ,"```-._/
+      `-._         ```-"                      |    /_          //
+          `.._                   _            ;   <_ \        //
+              ``-.___             `.           `-._ \ \      //
+                     `- <           `.     (\ _/)/ `.\/     //
+                         \            \     `       ^^^^^^^^^
+       ___________________________________________________________________________
+       
+       -->
+       <meta http-equiv="content-type" content="text/html; charset=utf-8">
+       <meta name="viewport" content="width=device-width, initial-scale=1.0">
+       <title>Fanfix</title>
+       <link rel="stylesheet" type="text/css" href="/style.css" />
+       <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+</head>
+<body>
+<div class='main'>
diff --git a/src/be/nikiroo/fanfix/library/web/search-32x32.png b/src/be/nikiroo/fanfix/library/web/search-32x32.png
new file mode 100644 (file)
index 0000000..92b716d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/search-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/search-64x64.png b/src/be/nikiroo/fanfix/library/web/search-64x64.png
new file mode 100644 (file)
index 0000000..93dbf6d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/search-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/style.css b/src/be/nikiroo/fanfix/library/web/style.css
new file mode 100644 (file)
index 0000000..c520d78
--- /dev/null
@@ -0,0 +1,196 @@
+html, body, .main {
+       margin: 0;
+       padding: 0;
+       font-family : Verdana, "Bitstream Vera Sans", "DejaVu Sans", Tahoma, Geneva, Arial, Sans-serif;
+       font-size: 12px;
+       DISABLED_color: #635c4a;
+       height: 100%;
+}
+
+table {
+       width: 100%;
+}
+
+.banner {
+}
+
+.banner .ico {
+       display: block;
+       height: 50px;
+       float: left;
+       padding: 10px;
+}
+
+.banner h1, .banner h2 {
+}
+
+.main {
+       display: block;
+}
+
+.message {
+        background-color: #ddffdd;
+        border: 1px solid #88dd88;
+        clear: left;
+        border-radius: 5px;
+        padding: 5px;
+        margin: 10px;
+}
+
+.error {
+        background-color: #ffdddd;
+        border: 1px solid #dd8888;
+        clear: left;
+        border-radius: 5px;
+        padding: 5px;
+        margin: 10px;
+}
+
+/* all links and clickable should show a pointer cursor */
+[onclick], h2[onclick]:before, h3[onclick]:before {
+       cursor: pointer;
+}
+
+a:hover {
+       background-color: rgb(225, 225, 225);
+}
+
+h2 {
+       border-bottom: 1px solid #AAA391;
+}
+
+h3 {
+       border-bottom: 1px solid #AAA391;
+       margin-left: 20px;
+}
+
+.login {
+       width: 250px;
+       display: flex;
+       margin: auto;
+       margin-top: 200px;
+       flex-direction: column;
+       border: 1px solid gray;
+       padding: 20px;
+       border-radius: 10px;
+}
+
+.login input {
+       margin: 5px;
+       min-height: 22px;
+}
+
+.login input[type='submit'] {
+       margin-top: 15px;
+}
+
+.breadcrumbs {
+}
+
+.filter {
+       padding: 10px;
+}
+
+.books {
+}
+
+.book_line {
+       width: 100%;
+       display: flex;
+}
+
+.book_line .link, .book_line .title {
+       flex-grow: 100;
+       padding-right: 5px;
+       padding-left: 5px;
+}
+
+.book_line .link {
+       text-decoration: none;
+}
+
+.book_line .cache_icon {
+       color: green;
+}
+
+.book_line .luid {
+       color: gray;
+       padding-right: 10px;
+       padding-left: 10px;
+}
+
+.book_line .title {
+       color: initial;
+}
+
+.book_line .author {
+       float: right;
+       color: blue;
+}
+
+.bar {
+       height: 64px;
+       width: 100%;
+       display: block;
+       background: white;
+       position: fixed;
+}
+
+.viewer {
+       padding-top: 64px;
+       padding-bottom: 64px;
+}
+
+a.viewer.link:hover {
+       background-color: transparent;
+}
+
+.viewer.text {
+       padding-left: 10px;
+       padding-right: 10px;
+}
+
+.bar.navbar {
+       padding-left: calc(50% - (4 * 64px / 2));
+}
+
+.bar.optionbar {
+       bottom: 0;      
+}
+
+.bar.optionbar.s1 {
+       padding-left: calc(50% - (1 * 64px / 2));
+}
+
+.bar.optionbar.s4 {
+       padding-left: calc(50% - (4 * 64px / 2));
+}
+
+.bar .button {
+       height: 54px;
+       width: 54px;
+       line-height: 64px;
+       display: inline-block;
+       text-align: center;
+       color: transparent;
+       text-decoration: none;
+       background-position: center;
+       background-repeat: no-repeat;
+       border-radius: 5px;
+       border: 1px solid #bac2e1;
+       margin: 5px;
+}
+
+.bar .button:hover {
+       background-color: bac2e1;
+}
+
+.bar .button.first    { background-image: url('/arrow_double_left-32x32.png');  }
+.bar .button.previous { background-image: url('/arrow_left-32x32.png');         }
+.bar .button.next     { background-image: url('/arrow_right-32x32.png');        }
+.bar .button.last     { background-image: url('/arrow_double_right-32x32.png'); }
+
+.bar .button.back       { background-image: url('/back-32x32.png');          }
+.bar .button.zoomreal   { background-image: url('/actual_size-32x32.png');   }
+.bar .button.zoomwidth  { background-image: url('/fit_to_width-32x32.png');  }
+.bar .button.zoomheight { background-image: url('/fit_to_height-32x32.png'); }
diff --git a/src/be/nikiroo/fanfix/library/web/unknown-32x32.png b/src/be/nikiroo/fanfix/library/web/unknown-32x32.png
new file mode 100644 (file)
index 0000000..d2315d1
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/unknown-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/unknown-64x64.png b/src/be/nikiroo/fanfix/library/web/unknown-64x64.png
new file mode 100644 (file)
index 0000000..261889d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/unknown-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/output/BasicOutput.java b/src/be/nikiroo/fanfix/output/BasicOutput.java
new file mode 100644 (file)
index 0000000..41634fa
--- /dev/null
@@ -0,0 +1,553 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.Version;
+
+/**
+ * This class is the base class used by the other output classes. It can be used
+ * outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * 
+ * @author niki
+ */
+public abstract class BasicOutput {
+       /**
+        * The supported output types for which we can get a {@link BasicOutput}
+        * object.
+        * 
+        * @author niki
+        */
+       public enum OutputType {
+               /** EPUB files created with this program */
+               EPUB,
+               /** Pure text file with some rules */
+               TEXT,
+               /** TEXT but with associated .info file */
+               INFO_TEXT,
+               /** DEBUG output to console */
+               SYSOUT,
+               /** ZIP with (PNG) images */
+               CBZ,
+               /** LaTeX file with "book" template */
+               LATEX,
+               /** HTML files in a dedicated directory */
+               HTML,
+
+               ;
+
+               @Override
+               public String toString() {
+                       return super.toString().toLowerCase();
+               }
+
+               /**
+                * A description of this output type.
+                * 
+                * @param longDesc
+                *            TRUE for the long description, FALSE for the short one
+                * 
+                * @return the description
+                */
+               public String getDesc(boolean longDesc) {
+                       StringId id = longDesc ? StringId.OUTPUT_DESC
+                                       : StringId.OUTPUT_DESC_SHORT;
+
+                       String desc = Instance.getInstance().getTrans().getStringX(id, this.name());
+
+                       if (desc == null) {
+                               desc = Instance.getInstance().getTrans().getString(id, this.toString());
+                       }
+
+                       if (desc == null || desc.isEmpty()) {
+                               desc = this.toString();
+                       }
+
+                       return desc;
+               }
+
+               /**
+                * The default extension to add to the output files.
+                * 
+                * @param readerTarget
+                *            TRUE to point to the main {@link Story} entry point for a
+                *            reader (for instance, the main entry point if this
+                *            {@link Story} is in a directory bundle), FALSE to point to
+                *            the main file even if it is a directory for instance
+                * 
+                * @return the extension
+                */
+               public String getDefaultExtension(boolean readerTarget) {
+                       BasicOutput output = BasicOutput.getOutput(this, false, false);
+                       if (output != null) {
+                               return output.getDefaultExtension(readerTarget);
+                       }
+
+                       return null;
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String)} after conversion to upper
+                * case.
+                * 
+                * @param typeName
+                *            the possible type name
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfUC(String typeName) {
+                       return OutputType.valueOf(typeName == null ? null : typeName
+                                       .toUpperCase());
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String)} after conversion to upper
+                * case but return def for NULL and empty instead of raising an
+                * exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * @param def
+                *            the default value
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfNullOkUC(String typeName, OutputType def) {
+                       if (typeName == null || typeName.isEmpty()) {
+                               return def;
+                       }
+
+                       return OutputType.valueOfUC(typeName);
+               }
+
+               /**
+                * Call {@link OutputType#valueOf(String)} after conversion to upper
+                * case but return def in case of error instead of raising an exception.
+                * 
+                * @param typeName
+                *            the possible type name
+                * @param def
+                *            the default value
+                * 
+                * @return NULL or the type
+                */
+               public static OutputType valueOfAllOkUC(String typeName, OutputType def) {
+                       try {
+                               return OutputType.valueOfUC(typeName);
+                       } catch (Exception e) {
+                               return def;
+                       }
+               }
+       }
+
+       /** The creator name (this program, by me!) */
+       static protected final String EPUB_CREATOR = "Fanfix "
+                       + Version.getCurrentVersion() + " (by Niki)";
+
+       /** The current best name for an image */
+       private String imageName;
+       private File targetDir;
+       private String targetName;
+       private OutputType type;
+       private boolean writeCover;
+       private boolean writeInfo;
+       private Progress storyPg;
+       private Progress chapPg;
+
+       /**
+        * Process the {@link Story} into the given target.
+        * 
+        * @param story
+        *            the {@link Story} to export
+        * @param target
+        *            the target where to save to (will not necessary be taken as is
+        *            by the processor, for instance an extension can be added)
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the actual main target saved, which can be slightly different
+        *         that the input one
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public File process(Story story, String target, Progress pg)
+                       throws IOException {
+               storyPg = pg;
+
+               File targetDir = null;
+               String targetName = null;
+               if (target != null) {
+                       target = new File(target).getAbsolutePath();
+                       targetDir = new File(target).getParentFile();
+                       targetName = new File(target).getName();
+
+                       String ext = getDefaultExtension(false);
+                       if (ext != null && !ext.isEmpty()) {
+                               if (targetName.toLowerCase().endsWith(ext)) {
+                                       targetName = targetName.substring(0, targetName.length()
+                                                       - ext.length());
+                               }
+                       }
+               }
+
+               return process(story, targetDir, targetName);
+       }
+
+       /**
+        * Process the {@link Story} into the given target.
+        * <p>
+        * This method is expected to be overridden in most cases.
+        * 
+        * @param story
+        *            the {@link Story} to export
+        * @param targetDir
+        *            the target dir where to save to
+        * @param targetName
+        *            the target filename (will not necessary be taken as is by the
+        *            processor, for instance an extension can be added)
+        * 
+        * 
+        * @return the actual main target saved, which can be slightly different
+        *         that the input one
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               this.targetDir = targetDir;
+               this.targetName = targetName;
+
+               writeStory(story);
+
+               return null;
+       }
+
+       /**
+        * The output type.
+        * 
+        * @return the type
+        */
+       public OutputType getType() {
+               return type;
+       }
+
+       /**
+        * Enable the creation of a .info file next to the resulting processed file.
+        * 
+        * @return TRUE to enable it
+        */
+       protected boolean isWriteInfo() {
+               return writeInfo;
+       }
+
+       /**
+        * Enable the creation of a cover file next to the resulting processed file
+        * if possible.
+        * 
+        * @return TRUE to enable it
+        */
+       protected boolean isWriteCover() {
+               return writeCover;
+       }
+
+       /**
+        * The output type.
+        * 
+        * @param type
+        *            the new type
+        * @param writeCover
+        *            TRUE to enable the creation of a cover if possible
+        * @param writeInfo
+        *            TRUE to enable the creation of a .info file
+        * 
+        * @return this
+        */
+       protected BasicOutput setType(OutputType type, boolean writeInfo,
+                       boolean writeCover) {
+               this.type = type;
+               this.writeInfo = writeInfo;
+               this.writeCover = writeCover;
+
+               return this;
+       }
+
+       /**
+        * The default extension to add to the output files.
+        * 
+        * @param readerTarget
+        *            TRUE to point to the main {@link Story} entry point for a
+        *            reader (for instance, the main entry point if this
+        *            {@link Story} is in a directory bundle), FALSE to point to the
+        *            main file even if it is a directory for instance
+        * 
+        * @return the extension
+        */
+       public String getDefaultExtension(
+                       @SuppressWarnings("unused") boolean readerTarget) {
+               return "";
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeStoryHeader(Story story) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeStoryFooter(Story story) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+       }
+
+       protected void writeStory(Story story) throws IOException {
+               if (storyPg == null) {
+                       storyPg = new Progress(0, story.getChapters().size() + 2);
+               } else {
+                       storyPg.setMinMax(0, story.getChapters().size() + 2);
+               }
+
+               String chapterNameNum = String.format("%03d", 0);
+               String paragraphNumber = String.format("%04d", 0);
+               imageName = paragraphNumber + "_" + chapterNameNum;
+
+               if (story.getMeta() != null) {
+                       story.getMeta().setType("" + getType());
+               }
+
+               if (isWriteCover()) {
+                       InfoCover.writeCover(targetDir, targetName, story.getMeta());
+               }
+               if (isWriteInfo()) {
+                       InfoCover.writeInfo(targetDir, targetName, story.getMeta());
+               }
+
+               storyPg.setProgress(1);
+
+               List<Progress> chapPgs = new ArrayList<Progress>(story.getChapters()
+                               .size());
+               for (Chapter chap : story) {
+                       chapPg = new Progress(0, chap.getParagraphs().size());
+                       storyPg.addProgress(chapPg, 1);
+                       chapPgs.add(chapPg);
+                       chapPg = null;
+               }
+
+               writeStoryHeader(story);
+               for (int i = 0; i < story.getChapters().size(); i++) {
+                       chapPg = chapPgs.get(i);
+                       writeChapter(story.getChapters().get(i));
+                       chapPg.setProgress(chapPg.getMax());
+                       chapPg = null;
+               }
+               writeStoryFooter(story);
+
+               storyPg.setProgress(storyPg.getMax());
+               storyPg = null;
+       }
+
+       protected void writeChapter(Chapter chap) throws IOException {
+               String chapterNameNum;
+               if (chap.getName() == null || chap.getName().isEmpty()) {
+                       chapterNameNum = String.format("%03d", chap.getNumber());
+               } else {
+                       chapterNameNum = String.format("%03d", chap.getNumber()) + "_"
+                                       + chap.getName().replace(" ", "_");
+               }
+
+               int num = 0;
+               String paragraphNumber = String.format("%04d", num++);
+               imageName = chapterNameNum + "_" + paragraphNumber;
+
+               writeChapterHeader(chap);
+               int i = 1;
+               for (Paragraph para : chap) {
+                       paragraphNumber = String.format("%04d", num++);
+                       imageName = chapterNameNum + "_" + paragraphNumber;
+                       writeParagraph(para);
+                       if (chapPg != null) {
+                               chapPg.setProgress(i++);
+                       }
+               }
+               writeChapterFooter(chap);
+       }
+
+       protected void writeParagraph(Paragraph para) throws IOException {
+               writeParagraphHeader(para);
+               writeTextLine(para.getType(), para.getContent());
+               writeParagraphFooter(para);
+       }
+
+       @SuppressWarnings("unused")
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+       }
+
+       /**
+        * Return the current best guess for an image name, based upon the current
+        * {@link Chapter} and {@link Paragraph}.
+        * 
+        * @param prefix
+        *            add the original target name as a prefix
+        * 
+        * @return the guessed name
+        */
+       protected String getCurrentImageBestName(boolean prefix) {
+               if (prefix) {
+                       return targetName + "_" + imageName;
+               }
+
+               return imageName;
+       }
+
+       /**
+        * Return the given word or sentence as <b>bold</b>.
+        * 
+        * @param word
+        *            the input
+        * 
+        * @return the bold output
+        */
+       protected String enbold(String word) {
+               return word;
+       }
+
+       /**
+        * Return the given word or sentence as <i>italic</i>.
+        * 
+        * @param word
+        *            the input
+        * 
+        * @return the italic output
+        */
+       protected String italize(String word) {
+               return word;
+       }
+
+       /**
+        * Decorate the given text with <b>bold</b> and <i>italic</i> words,
+        * according to {@link BasicOutput#enbold(String)} and
+        * {@link BasicOutput#italize(String)}.
+        * 
+        * @param text
+        *            the input
+        * 
+        * @return the decorated output
+        */
+       protected String decorateText(String text) {
+               StringBuilder builder = new StringBuilder();
+
+               int bold = -1;
+               int italic = -1;
+               char prev = '\0';
+               for (char car : text.toCharArray()) {
+                       switch (car) {
+                       case '*':
+                               if (bold >= 0 && prev != ' ') {
+                                       String data = builder.substring(bold);
+                                       builder.setLength(bold);
+                                       builder.append(enbold(data));
+                                       bold = -1;
+                               } else if (bold < 0
+                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
+                                       bold = builder.length();
+                               } else {
+                                       builder.append(car);
+                               }
+
+                               break;
+                       case '_':
+                               if (italic >= 0 && prev != ' ') {
+                                       String data = builder.substring(italic);
+                                       builder.setLength(italic);
+                                       builder.append(enbold(data));
+                                       italic = -1;
+                               } else if (italic < 0
+                                               && (prev == ' ' || prev == '\0' || prev == '\n')) {
+                                       italic = builder.length();
+                               } else {
+                                       builder.append(car);
+                               }
+
+                               break;
+                       default:
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (bold >= 0) {
+                       builder.insert(bold, '*');
+               }
+
+               if (italic >= 0) {
+                       builder.insert(italic, '_');
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Return a {@link BasicOutput} object compatible with the given
+        * {@link OutputType}.
+        * 
+        * @param type
+        *            the type
+        * @param writeCover
+        *            TRUE to enable the creation of a cover if possible to be saved
+        *            next to the main target file
+        * @param writeInfo
+        *            TRUE to enable the creation of a .info file to be saved next
+        *            to the main target file
+        * 
+        * @return the {@link BasicOutput}
+        */
+       public static BasicOutput getOutput(OutputType type, boolean writeInfo,
+                       boolean writeCover) {
+               if (type != null) {
+                       switch (type) {
+                       case EPUB:
+                               return new Epub().setType(type, writeInfo, writeCover);
+                       case TEXT:
+                               return new Text().setType(type, writeInfo, true);
+                       case INFO_TEXT:
+                               return new InfoText().setType(type, true, true);
+                       case SYSOUT:
+                               return new Sysout().setType(type, false, false);
+                       case CBZ:
+                               return new Cbz().setType(type, writeInfo, writeCover);
+                       case LATEX:
+                               return new LaTeX().setType(type, writeInfo, writeCover);
+                       case HTML:
+                               return new Html().setType(type, writeInfo, writeCover);
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Cbz.java b/src/be/nikiroo/fanfix/output/Cbz.java
new file mode 100644 (file)
index 0000000..ee671e7
--- /dev/null
@@ -0,0 +1,101 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+
+class Cbz extends BasicOutput {
+       private File dir;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               File target = new File(targetDir, targetName);
+
+               dir = Instance.getInstance().getTempFiles().createTempDir("fanfic-reader-cbz-dir");
+               try {
+                       // will also save the images! (except the cover -> false)
+                       BasicOutput
+                                       .getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
+                                       // Force cover to FALSE:
+                                       .setType(OutputType.TEXT, isWriteInfo(), false)
+                                       .process(story, dir, targetNameOrig);
+
+                       try {
+                               super.process(story, targetDir, targetNameOrig);
+                       } finally {
+                       }
+
+                       InfoCover.writeInfo(dir, targetNameOrig, story.getMeta());
+                       if (story.getMeta() != null && !story.getMeta().isFakeCover()) {
+                               InfoCover.writeCover(dir, targetNameOrig, story.getMeta());
+                       }
+
+                       IOUtils.writeSmallFile(dir, "version", "3.0");
+
+                       IOUtils.zip(dir, target, true);
+               } finally {
+                       IOUtils.deltree(dir);
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".cbz";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               MetaData meta = story.getMeta();
+
+               StringBuilder builder = new StringBuilder();
+               if (meta != null && meta.getResume() != null) {
+                       for (Paragraph para : story.getMeta().getResume()) {
+                               builder.append(para.getContent());
+                               builder.append("\n");
+                       }
+               }
+
+               BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(new File(dir, "URL")), "UTF-8"));
+               try {
+                       if (meta != null) {
+                               writer.write(meta.getUrl());
+                       }
+               } finally {
+                       writer.close();
+               }
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(new File(dir, "SUMMARY")), "UTF-8"));
+               try {
+                       String title = "";
+                       if (meta != null && meta.getTitle() != null) {
+                               title = meta.getTitle();
+                       }
+
+                       writer.write(title);
+                       if (meta != null && meta.getAuthor() != null) {
+                               writer.write("\n©");
+                               writer.write(meta.getAuthor());
+                       }
+                       writer.write("\n\n");
+                       writer.write(builder.toString());
+               } finally {
+                       writer.close();
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Epub.java b/src/be/nikiroo/fanfix/output/Epub.java
new file mode 100644 (file)
index 0000000..fc2dc8c
--- /dev/null
@@ -0,0 +1,514 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.StringUtils;
+
+class Epub extends BasicOutput {
+       private File tmpDir;
+       private BufferedWriter writer;
+       private boolean inDialogue = false;
+       private boolean inNormal = false;
+       private File images;
+       private boolean nextParaIsCover = true;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               tmpDir = Instance.getInstance().getTempFiles().createTempDir("fanfic-reader-epub");
+               tmpDir.delete();
+
+               if (!tmpDir.mkdir()) {
+                       throw new IOException(
+                                       "Cannot create a temporary directory: no space left on device?");
+               }
+
+               super.process(story, targetDir, targetNameOrig);
+
+               File epub = null;
+               try {
+                       // "Originals"
+                       File data = new File(tmpDir, "DATA");
+                       data.mkdir();
+                       BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(),
+                                       isWriteCover()).process(story, data, targetNameOrig);
+                       InfoCover.writeInfo(data, targetNameOrig, story.getMeta());
+                       IOUtils.writeSmallFile(data, "version", "3.0");
+
+                       // zip/epub
+                       epub = new File(targetDir, targetName);
+                       IOUtils.zip(tmpDir, epub, true);
+
+                       OutputStream out = new FileOutputStream(epub);
+                       try {
+                               ZipOutputStream zip = new ZipOutputStream(out);
+                               try {
+                                       // "mimetype" MUST be the first element and not compressed
+                                       zip.setLevel(ZipOutputStream.STORED);
+                                       File mimetype = new File(tmpDir, "mimetype");
+                                       IOUtils.writeSmallFile(tmpDir, "mimetype",
+                                                       "application/epub+zip");
+                                       ZipEntry entry = new ZipEntry("mimetype");
+                                       entry.setExtra(new byte[] {});
+                                       zip.putNextEntry(entry);
+                                       FileInputStream in = new FileInputStream(mimetype);
+                                       try {
+                                               IOUtils.write(in, zip);
+                                       } finally {
+                                               in.close();
+                                       }
+                                       IOUtils.deltree(mimetype);
+                                       zip.setLevel(ZipOutputStream.DEFLATED);
+                                       //
+
+                                       IOUtils.zip(zip, "", tmpDir, true);
+                               } finally {
+                                       zip.close();
+                               }
+                       } finally {
+                               out.close();
+                       }
+               } finally {
+                       IOUtils.deltree(tmpDir);
+                       tmpDir = null;
+               }
+
+               return epub;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".epub";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               File ops = new File(tmpDir, "OPS");
+               ops.mkdirs();
+               File css = new File(ops, "css");
+               css.mkdirs();
+               images = new File(ops, "images");
+               images.mkdirs();
+               File metaInf = new File(tmpDir, "META-INF");
+               metaInf.mkdirs();
+
+               // META-INF
+               String containerContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+                               + "<container xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\" version=\"1.0\">\n"
+                               + "\t<rootfiles>\n"
+                               + "\t\t<rootfile full-path=\"OPS/epb.opf\" media-type=\"application/oebps-package+xml\"/>\n"
+                               + "\t</rootfiles>\n" + "</container>\n";
+
+               IOUtils.writeSmallFile(metaInf, "container.xml", containerContent);
+
+               // OPS/css
+               InputStream inStyle = getClass().getResourceAsStream("epub.style.css");
+               if (inStyle == null) {
+                       throw new IOException("Cannot find style.css resource");
+               }
+               try {
+                       IOUtils.write(inStyle, new File(css, "style.css"));
+               } finally {
+                       inStyle.close();
+               }
+
+               // OPS/images
+               if (story.getMeta() != null && story.getMeta().getCover() != null) {
+                       File file = new File(images, "cover");
+                       try {
+                               Instance.getInstance().getCache().saveAsImage(story.getMeta().getCover(), file, true);
+                       } catch (Exception e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+               }
+
+               // OPS/* except chapters
+               IOUtils.writeSmallFile(ops, "epb.ncx", generateNcx(story));
+               IOUtils.writeSmallFile(ops, "epb.opf", generateOpf(story));
+               IOUtils.writeSmallFile(ops, "title.xhtml", generateTitleXml(story));
+
+               // Resume
+               if (story.getMeta() != null && story.getMeta().getResume() != null) {
+                       writeChapter(story.getMeta().getResume());
+               }
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String filename = String.format("%s%03d%s", "chapter-",
+                               chap.getNumber(), ".xhtml");
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(new File(tmpDir + File.separator + "OPS",
+                                               filename)), "UTF-8"));
+               inDialogue = false;
+               inNormal = false;
+               try {
+                       String title = "Chapter " + chap.getNumber();
+                       String nameOrNum = Integer.toString(chap.getNumber());
+                       if (chap.getName() != null && !chap.getName().isEmpty()) {
+                               title += ": " + chap.getName();
+                               nameOrNum = chap.getName();
+                       }
+
+                       writer.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+                       writer.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
+                       writer.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
+                       writer.write("\n<head>");
+                       writer.write("\n        <title>" + StringUtils.xmlEscape(title)
+                                       + "</title>");
+                       writer.write("\n        <link rel='stylesheet' href='css/style.css' type='text/css'/>");
+                       writer.write("\n</head>");
+                       writer.write("\n<body>");
+                       writer.write("\n        <h2>");
+                       writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
+                                       + chap.getNumber() + "</span>:</span> ");
+                       writer.write("\n                <span class='chaptitle'>"
+                                       + StringUtils.xmlEscape(nameOrNum) + "</span>");
+                       writer.write("\n        </h2>");
+                       writer.write("\n        ");
+                       writer.write("\n        <div class='chapter_content'>\n");
+               } catch (Exception e) {
+                       writer.close();
+                       throw new IOException(e);
+               }
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               try {
+                       if (inDialogue) {
+                               writer.write("          </div>\n");
+                               inDialogue = false;
+                       }
+                       if (inNormal) {
+                               writer.write("          </div>\n");
+                               inNormal = false;
+                       }
+                       writer.write("  </div>\n</body>\n</html>\n");
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
+                       writer.write("          <div class='dialogues'>\n");
+                       inDialogue = true;
+               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+
+               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
+                       writer.write("          <div class='normals'>\n");
+                       inNormal = true;
+               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               switch (para.getType()) {
+               case BLANK:
+                       writer.write("          <div class='blank'></div>");
+                       break;
+               case BREAK:
+                       writer.write("          <hr class='break'/>");
+                       break;
+               case NORMAL:
+                       writer.write("          <span class='normal'>");
+                       break;
+               case QUOTE:
+                       writer.write("                  <div class='dialogue'>&mdash; ");
+                       break;
+               case IMAGE:
+                       File file = new File(images, getCurrentImageBestName(false));
+                       Instance.getInstance().getCache().saveAsImage(para.getContentImage(), file, nextParaIsCover);
+                       writer.write("                  <img alt='page image' class='page-image' src='images/"
+                                       + getCurrentImageBestName(false) + "'/>");
+                       break;
+               }
+
+               nextParaIsCover = false;
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               switch (para.getType()) {
+               case NORMAL:
+                       writer.write("</span>\n");
+                       break;
+               case QUOTE:
+                       writer.write("</div>\n");
+                       break;
+               default:
+                       writer.write("\n");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case QUOTE:
+               case NORMAL:
+                       writer.write(decorateText(StringUtils.xmlEscape(line)));
+                       break;
+               default:
+                       break;
+               }
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "<strong>" + word + "</strong>";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "<emph>" + word + "</emph>";
+       }
+
+       private String generateNcx(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String uuid = "";
+               String author = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       uuid = meta.getUuid();
+                       author = meta.getAuthor();
+                       title = meta.getTitle();
+               }
+
+               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+               builder.append("\n<!DOCTYPE ncx");
+               builder.append("\nPUBLIC \"-//NISO//DTD ncx 2005-1//EN\" \"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd\">");
+               builder.append("\n<ncx xmlns=\"http://www.daisy.org/z3986/2005/ncx/\" version=\"2005-1\">");
+               builder.append("\n      <head>");
+               builder.append("\n              <!--The following four metadata items are required for all");
+               builder.append("\n                  NCX documents, including those conforming to the relaxed");
+               builder.append("\n                  constraints of OPS 2.0-->");
+               builder.append("\n              <meta name=\"dtb:uid\" content=\""
+                               + StringUtils.xmlEscapeQuote(uuid) + "\"/>");
+               builder.append("\n              <meta name=\"dtb:depth\" content=\"1\"/>");
+               builder.append("\n              <meta name=\"dtb:totalPageCount\" content=\"0\"/>");
+               builder.append("\n              <meta name=\"dtb:maxPageNumber\" content=\"0\"/>");
+               builder.append("\n              <meta name=\"epub-creator\" content=\""
+                               + StringUtils.xmlEscapeQuote(EPUB_CREATOR) + "\"/>");
+               builder.append("\n      </head>");
+               builder.append("\n      <docTitle>");
+               builder.append("\n              <text>" + StringUtils.xmlEscape(title) + "</text>");
+               builder.append("\n      </docTitle>");
+               builder.append("\n      <docAuthor>");
+
+               builder.append("\n              <text>" + StringUtils.xmlEscape(author) + "</text>");
+               builder.append("\n      </docAuthor>");
+               builder.append("\n      <navMap>");
+               builder.append("\n              <navPoint id=\"navpoint-1\" playOrder=\"1\">");
+               builder.append("\n                      <navLabel>");
+               builder.append("\n                              <text>Title Page</text>");
+               builder.append("\n                      </navLabel>");
+               builder.append("\n                      <content src=\"title.xhtml\"/>");
+               builder.append("\n              </navPoint>");
+
+               int navPoint = 2; // 1 is above
+
+               if (story.getMeta() != null & story.getMeta().getResume() != null) {
+                       Chapter chap = story.getMeta().getResume();
+                       generateNcx(chap, builder, navPoint++);
+               }
+
+               for (Chapter chap : story) {
+                       generateNcx(chap, builder, navPoint++);
+               }
+
+               builder.append("\n      </navMap>");
+               builder.append("\n</ncx>\n");
+
+               return builder.toString();
+       }
+
+       private void generateNcx(Chapter chap, StringBuilder builder, int navPoint) {
+               String name;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       name = Instance.getInstance().getTrans().getString(StringId.CHAPTER_NAMED, chap.getNumber(),
+                                       chap.getName());
+               } else {
+                       name = Instance.getInstance().getTrans().getString(StringId.CHAPTER_UNNAMED, chap.getNumber());
+               }
+
+               String nnn = String.format("%03d", (navPoint - 2));
+
+               builder.append("\n              <navPoint id=\"navpoint-" + navPoint
+                               + "\" playOrder=\"" + navPoint + "\">");
+               builder.append("\n                      <navLabel>");
+               builder.append("\n                              <text>" + name + "</text>");
+               builder.append("\n                      </navLabel>");
+               builder.append("\n                      <content src=\"chapter-" + nnn + ".xhtml\"/>");
+               builder.append("\n              </navPoint>\n");
+       }
+
+       private String generateOpf(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String uuid = "";
+               String author = "";
+               String date = "";
+               String publisher = "";
+               String subject = "";
+               String source = "";
+               String lang = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       uuid = meta.getUuid();
+                       author = meta.getAuthor();
+                       date = meta.getDate();
+                       publisher = meta.getPublisher();
+                       subject = meta.getSubject();
+                       source = meta.getSource();
+                       lang = meta.getLang();
+               }
+
+               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+               builder.append("\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"BookId\" version=\"2.0\">");
+               builder.append("\n   <metadata xmlns:opf=\"http://www.idpf.org/2007/opf\"");
+               builder.append("\n             xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
+               builder.append("\n      <dc:title>" + StringUtils.xmlEscape(title)
+                               + "</dc:title>");
+               builder.append("\n      <dc:creator opf:role=\"aut\" opf:file-as=\""
+                               + StringUtils.xmlEscapeQuote(author) + "\">"
+                               + StringUtils.xmlEscape(author) + "</dc:creator>");
+               builder.append("\n      <dc:date opf:event=\"original-publication\">"
+                               + StringUtils.xmlEscape(date) + "</dc:date>");
+               builder.append("\n      <dc:publisher>"
+                               + StringUtils.xmlEscape(publisher) + "</dc:publisher>");
+               builder.append("\n      <dc:date opf:event=\"epub-publication\"></dc:date>");
+               builder.append("\n      <dc:subject>" + StringUtils.xmlEscape(subject)
+                               + "</dc:subject>");
+               builder.append("\n      <dc:source>" + StringUtils.xmlEscape(source)
+                               + "</dc:source>");
+               builder.append("\n      <dc:rights>Not for commercial use.</dc:rights>");
+               builder.append("\n      <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"
+                               + StringUtils.xmlEscape(uuid) + "</dc:identifier>");
+               builder.append("\n      <dc:language>" + StringUtils.xmlEscape(lang)
+                               + "</dc:language>");
+               builder.append("\n   </metadata>");
+               builder.append("\n   <manifest>");
+               builder.append("\n      <!-- Content Documents -->");
+               builder.append("\n      <item id=\"titlepage\" href=\"title.xhtml\" media-type=\"application/xhtml+xml\"/>");
+               for (int i = 0; i <= story.getChapters().size(); i++) {
+                       String name = String.format("%s%03d", "chapter-", i);
+                       builder.append("\n      <item id=\""
+                                       + StringUtils.xmlEscapeQuote(name) + "\" href=\""
+                                       + StringUtils.xmlEscapeQuote(name)
+                                       + ".xhtml\" media-type=\"application/xhtml+xml\"/>");
+               }
+
+               builder.append("\n      <!-- CSS Style Sheets -->");
+               builder.append("\n      <item id=\"style-css\" href=\"css/style.css\" media-type=\"text/css\"/>");
+
+               builder.append("\n      <!-- Images -->");
+
+               if (story.getMeta() != null && story.getMeta().getCover() != null) {
+                       String format = Instance.getInstance().getConfig()
+                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER)
+                                       .toLowerCase();
+                       builder.append("\n      <item id=\"cover\" href=\"images/cover."
+                                       + format + "\" media-type=\"image/png\"/>");
+               }
+
+               builder.append("\n      <!-- NCX -->");
+               builder.append("\n      <item id=\"ncx\" href=\"epb.ncx\" media-type=\"application/x-dtbncx+xml\"/>");
+               builder.append("\n   </manifest>");
+               builder.append("\n   <spine toc=\"ncx\">");
+               builder.append("\n      <itemref idref=\"titlepage\" linear=\"yes\"/>");
+               for (int i = 0; i <= story.getChapters().size(); i++) {
+                       String name = String.format("%s%03d", "chapter-", i);
+                       builder.append("\n      <itemref idref=\""
+                                       + StringUtils.xmlEscapeQuote(name) + "\" linear=\"yes\"/>");
+               }
+               builder.append("\n   </spine>");
+               builder.append("\n</package>\n");
+
+               return builder.toString();
+       }
+
+       private String generateTitleXml(Story story) {
+               StringBuilder builder = new StringBuilder();
+
+               String title = "";
+               String tags = "";
+               String author = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       if (meta.getTags() != null) {
+                               for (String tag : meta.getTags()) {
+                                       if (!tags.isEmpty()) {
+                                               tags += ", ";
+                                       }
+                                       tags += tag;
+                               }
+
+                               if (!tags.isEmpty()) {
+                                       tags = "(" + tags + ")";
+                               }
+                       }
+                       author = meta.getAuthor();
+               }
+
+               String format = Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+
+               builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
+               builder.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
+               builder.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
+               builder.append("\n<head>");
+               builder.append("\n      <title>" + StringUtils.xmlEscape(title) + "</title>");
+               builder.append("\n      <link rel=\"stylesheet\" href=\"css/style.css\" type=\"text/css\"/>");
+               builder.append("\n</head>");
+               builder.append("\n<body>");
+               builder.append("\n      <div class=\"titlepage\">");
+               builder.append("\n              <h1>" + StringUtils.xmlEscape(title) + "</h1>");
+               builder.append("\n                      <div class=\"type\">"
+                               + StringUtils.xmlEscape(tags) + "</div>");
+               builder.append("\n              <div class=\"cover\">");
+               builder.append("\n                      <img alt=\"cover image\" src=\"images/cover."
+                               + format + "\"></img>");
+               builder.append("\n              </div>");
+               builder.append("\n              <div class=\"author\">"
+                               + StringUtils.xmlEscape(author) + "</div>");
+               builder.append("\n      </div>");
+               builder.append("\n</body>");
+               builder.append("\n</html>\n");
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Html.java b/src/be/nikiroo/fanfix/output/Html.java
new file mode 100644 (file)
index 0000000..f81ea1d
--- /dev/null
@@ -0,0 +1,264 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.StringUtils;
+
+class Html extends BasicOutput {
+       private File dir;
+       protected BufferedWriter writer;
+       private boolean inDialogue = false;
+       private boolean inNormal = false;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+
+               File target = new File(targetDir, targetName);
+               target.mkdir();
+               dir = target;
+
+               target = new File(targetDir, targetName + getDefaultExtension(true));
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(target), "UTF-8"));
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               // write a copy of the originals inside
+               InfoCover.writeInfo(dir, targetName, story.getMeta());
+               InfoCover.writeCover(dir, targetName, story.getMeta());
+               BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
+                               .process(story, dir, targetNameOrig);
+
+               if (story.getMeta().getCover() != null) {
+                       Instance.getInstance().getCache().saveAsImage(story.getMeta().getCover(), new File(dir, "cover"), true);
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               if (readerTarget) {
+                       return File.separator + "index.html";
+               }
+
+               return "";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String title = "";
+               String tags = "";
+               String author = "";
+               Chapter resume = null;
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = meta.getTitle();
+                       resume = meta.getResume();
+                       if (meta.getTags() != null) {
+                               for (String tag : meta.getTags()) {
+                                       if (!tags.isEmpty()) {
+                                               tags += ", ";
+                                       }
+                                       tags += tag;
+                               }
+
+                               if (!tags.isEmpty()) {
+                                       tags = "(" + tags + ")";
+                               }
+                       }
+                       author = meta.getAuthor();
+               }
+
+               String format = Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+
+               InputStream inStyle = getClass().getResourceAsStream("html.style.css");
+               if (inStyle == null) {
+                       throw new IOException("Cannot find style.css resource");
+               }
+               try {
+                       IOUtils.write(inStyle, new File(dir, "style.css"));
+               } finally {
+                       inStyle.close();
+               }
+
+               writer.write("<!DOCTYPE html>");
+               writer.write("\n<html>");
+               writer.write("\n<head>");
+               writer.write("\n        <meta http-equiv='content-type' content='text/html; charset=utf-8'>");
+               writer.write("\n        <meta name='viewport' content='width=device-width, initial-scale=1.0'>");
+               writer.write("\n        <link rel='stylesheet' type='text/css' href='style.css'>");
+               writer.write("\n        <title>" + StringUtils.xmlEscape(title) + "</title>");
+               writer.write("\n</head>");
+               writer.write("\n<body>\n");
+
+               writer.write("\n        <div class=\"titlepage\">");
+               writer.write("\n                <h1>" + StringUtils.xmlEscape(title) + "</h1>");
+               writer.write("\n                        <div class=\"type\">" + StringUtils.xmlEscape(tags)
+                               + "</div>");
+               writer.write("\n                <div class=\"cover\">");
+               writer.write("\n                        <img src=\"cover." + format + "\"></img>");
+               writer.write("\n                </div>");
+               writer.write("\n                <div class=\"author\">"
+                               + StringUtils.xmlEscape(author) + "</div>");
+               writer.write("\n        </div>");
+
+               writer.write("\n        <hr/><br/>");
+
+               if (resume != null) {
+                       for (Paragraph para : resume) {
+                               writeParagraph(para);
+                       }
+                       if (inDialogue) {
+                               writer.write("          </div>\n");
+                               inDialogue = false;
+                       }
+                       if (inNormal) {
+                               writer.write("          </div>\n");
+                               inNormal = false;
+                       }
+               }
+
+               writer.write("\n        <br/>");
+       }
+
+       @Override
+       protected void writeStoryFooter(Story story) throws IOException {
+               writer.write("</body>\n");
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String nameOrNumber;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       nameOrNumber = chap.getName();
+               } else {
+                       nameOrNumber = Integer.toString(chap.getNumber());
+               }
+
+               writer.write("\n        <h2>");
+               writer.write("\n                <span class='chap'>Chapter <span class='chapnumber'>"
+                               + chap.getNumber() + "</span>:</span> ");
+               writer.write("\n                <span class='chaptitle'>"
+                               + StringUtils.xmlEscape(nameOrNumber) + "</span>");
+               writer.write("\n        </h2>");
+               writer.write("\n        ");
+               writer.write("\n        <div class='chapter_content'>\n");
+
+               inDialogue = false;
+               inNormal = false;
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               if (inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+               if (inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               writer.write("\n        </div>");
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
+                       writer.write("          <div class='dialogues'>\n");
+                       inDialogue = true;
+               } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
+                       writer.write("          </div>\n");
+                       inDialogue = false;
+               }
+
+               if (para.getType() == ParagraphType.NORMAL && !inNormal) {
+                       writer.write("          <div class='normals'>\n");
+                       inNormal = true;
+               } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
+                       writer.write("          </div>\n");
+                       inNormal = false;
+               }
+
+               switch (para.getType()) {
+               case BLANK:
+                       writer.write("          <div class='blank'></div>");
+                       break;
+               case BREAK:
+                       writer.write("          <hr class='break'/>");
+                       break;
+               case NORMAL:
+                       writer.write("          <span class='normal'>");
+                       break;
+               case QUOTE:
+                       writer.write("                  <div class='dialogue'>&mdash; ");
+                       break;
+               case IMAGE:
+                       // TODO check if images work OK
+                       writer.write("<a href='"
+                                       + StringUtils.xmlEscapeQuote(para.getContent()) + "'>"
+                                       + StringUtils.xmlEscape(para.getContent()) + "</a>");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               switch (para.getType()) {
+               case NORMAL:
+                       writer.write("</span>\n");
+                       break;
+               case QUOTE:
+                       writer.write("</div>\n");
+                       break;
+               default:
+                       writer.write("\n");
+                       break;
+               }
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case QUOTE:
+               case NORMAL:
+                       writer.write(decorateText(StringUtils.xmlEscape(line)));
+                       break;
+               default:
+                       break;
+               }
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "<strong>" + word + "</strong>";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "<emph>" + word + "</emph>";
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/InfoCover.java b/src/be/nikiroo/fanfix/output/InfoCover.java
new file mode 100644 (file)
index 0000000..d8ca49a
--- /dev/null
@@ -0,0 +1,90 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+
+public class InfoCover {
+       public static void writeInfo(File targetDir, String targetName,
+                       MetaData meta) throws IOException {
+               File info = new File(targetDir, targetName + ".info");
+
+               BufferedWriter infoWriter = null;
+               try {
+                       infoWriter = new BufferedWriter(new OutputStreamWriter(
+                                       new FileOutputStream(info), "UTF-8"));
+
+                       if (meta != null) {
+                               String tags = "";
+                               if (meta.getTags() != null) {
+                                       for (String tag : meta.getTags()) {
+                                               if (!tags.isEmpty()) {
+                                                       tags += ", ";
+                                               }
+                                               tags += tag;
+                                       }
+                               }
+
+                               writeMeta(infoWriter, "TITLE", meta.getTitle());
+                               writeMeta(infoWriter, "AUTHOR", meta.getAuthor());
+                               writeMeta(infoWriter, "DATE", meta.getDate());
+                               writeMeta(infoWriter, "SUBJECT", meta.getSubject());
+                               writeMeta(infoWriter, "SOURCE", meta.getSource());
+                               writeMeta(infoWriter, "URL", meta.getUrl());
+                               writeMeta(infoWriter, "TAGS", tags);
+                               writeMeta(infoWriter, "UUID", meta.getUuid());
+                               writeMeta(infoWriter, "LUID", meta.getLuid());
+                               writeMeta(infoWriter, "LANG", meta.getLang() == null ? ""
+                                               : meta.getLang().toLowerCase());
+                               writeMeta(infoWriter, "IMAGES_DOCUMENT",
+                                               meta.isImageDocument() ? "true" : "false");
+                               writeMeta(infoWriter, "TYPE", meta.getType());
+                               if (meta.getCover() != null) {
+                                       String format = Instance.getInstance().getConfig()
+                                                       .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+                                       writeMeta(infoWriter, "COVER", targetName + "." + format);
+                               } else {
+                                       writeMeta(infoWriter, "COVER", "");
+                               }
+                               writeMeta(infoWriter, "EPUBCREATOR", BasicOutput.EPUB_CREATOR);
+                               writeMeta(infoWriter, "PUBLISHER", meta.getPublisher());
+                               writeMeta(infoWriter, "WORDCOUNT",
+                                               Long.toString(meta.getWords()));
+                               writeMeta(infoWriter, "CREATION_DATE", meta.getCreationDate());
+                               writeMeta(infoWriter, "FAKE_COVER",
+                                               Boolean.toString(meta.isFakeCover()));
+                       }
+               } finally {
+                       if (infoWriter != null) {
+                               infoWriter.close();
+                       }
+               }
+       }
+
+       public static void writeCover(File targetDir, String targetName,
+                       MetaData meta) {
+               if (meta != null && meta.getCover() != null) {
+                       try {
+                               Instance.getInstance().getCache().saveAsImage(meta.getCover(), new File(targetDir, targetName), true);
+                       } catch (IOException e) {
+                               // Allow to continue without cover
+                               Instance.getInstance().getTraceHandler().error(new IOException("Failed to save the cover image", e));
+                       }
+               }
+       }
+
+       private static void writeMeta(BufferedWriter writer, String key,
+                       String value) throws IOException {
+               if (value == null) {
+                       value = "";
+               }
+
+               writer.write(String.format("%s=\"%s\"\n", key, value.replace("\"", "'")));
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/InfoText.java b/src/be/nikiroo/fanfix/output/InfoText.java
new file mode 100644 (file)
index 0000000..935da87
--- /dev/null
@@ -0,0 +1,69 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+
+class InfoText extends Text {
+       // quote chars
+       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return "";
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               writer.write("\n");
+
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       writer.write(Instance.getInstance().getTrans().getString(StringId.CHAPTER_NAMED, chap.getNumber(),
+                                       chap.getName()));
+               } else {
+                       writer.write(Instance.getInstance().getTrans().getString(StringId.CHAPTER_UNNAMED, chap.getNumber()));
+               }
+
+               writer.write("\n\n");
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case NORMAL:
+               case QUOTE:
+                       StringBuilder builder = new StringBuilder();
+                       for (char car : line.toCharArray()) {
+                               if (car == '—') {
+                                       builder.append("---");
+                               } else if (car == '–') {
+                                       builder.append("--");
+                               } else if (car == openDoubleQuote) {
+                                       builder.append("\"");
+                               } else if (car == closeDoubleQuote) {
+                                       builder.append("\"");
+                               } else if (car == openQuote) {
+                                       builder.append("'");
+                               } else if (car == closeQuote) {
+                                       builder.append("'");
+                               } else {
+                                       builder.append(car);
+                               }
+                       }
+
+                       line = builder.toString();
+                       break;
+               default:
+                       break;
+               }
+
+               super.writeTextLine(type, line);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/LaTeX.java b/src/be/nikiroo/fanfix/output/LaTeX.java
new file mode 100644 (file)
index 0000000..a406fc3
--- /dev/null
@@ -0,0 +1,180 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+
+class LaTeX extends BasicOutput {
+       protected BufferedWriter writer;
+       private boolean lastWasQuote = false;
+
+       // quote chars
+       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               File target = new File(targetDir, targetName);
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(target), "UTF-8"));
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".tex";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String date = "";
+               String author = "";
+               String title = "\\title{}";
+               String lang = "";
+               if (story.getMeta() != null) {
+                       MetaData meta = story.getMeta();
+                       title = "\\title{" + latexEncode(meta.getTitle()) + "}";
+                       date = "\\date{" + latexEncode(meta.getDate()) + "}";
+                       author = "\\author{" + latexEncode(meta.getAuthor()) + "}";
+                       lang = meta.getLang().toLowerCase();
+                       if (lang != null && !lang.isEmpty()) {
+                               lang = Instance.getInstance().getConfig().getStringX(Config.CONF_LATEX_LANG, lang);
+                               if (lang == null) {
+                                       System.err.println(Instance.getInstance().getTrans().getString(StringId.LATEX_LANG_UNKNOWN, lang));
+                               }
+                       }
+               }
+
+               writer.append("%\n");
+               writer.append("% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.\n");
+               writer.append("%\n\n");
+               writer.append("\\documentclass[a4paper]{book}\n");
+               if (lang != null && !lang.isEmpty()) {
+                       writer.append("\\usepackage[" + lang + "]{babel}\n");
+               }
+               writer.append("\\usepackage[utf8]{inputenc}\n");
+               writer.append("\\usepackage[T1]{fontenc}\n");
+               writer.append("\\usepackage{lmodern}\n");
+               writer.append("\\newcommand{\\br}{\\vspace{10 mm}}\n");
+               writer.append("\\newcommand{\\say}{--- \\noindent\\emph}\n");
+               writer.append("\\hyphenpenalty=1000\n");
+               writer.append("\\tolerance=5000\n");
+               writer.append("\\begin{document}\n");
+               if (story.getMeta() != null && story.getMeta().getDate() != null)
+                       writer.append(date + "\n");
+               writer.append(title + "\n");
+               writer.append(author + "\n");
+               writer.append("\\maketitle\n");
+               writer.append("\n");
+
+               // TODO: cover
+       }
+
+       @Override
+       protected void writeStoryFooter(Story story) throws IOException {
+               writer.append("\\end{document}\n");
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               writer.append("\n\n\\chapter{" + latexEncode(chap.getName()) + "}"
+                               + "\n");
+       }
+
+       @Override
+       protected void writeChapterFooter(Chapter chap) throws IOException {
+               writer.write("\n");
+       }
+
+       @Override
+       protected String enbold(String word) {
+               return "\\textsc{" + word + "}";
+       }
+
+       @Override
+       protected String italize(String word) {
+               return "\\emph{" + word + "}";
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+
+               line = decorateText(latexEncode(line));
+
+               switch (type) {
+               case BLANK:
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case BREAK:
+                       writer.write("\n\\br");
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case NORMAL:
+                       writer.write(line);
+                       writer.write("\n");
+                       lastWasQuote = false;
+                       break;
+               case QUOTE:
+                       writer.write("\n\\say{" + line + "}\n");
+                       if (lastWasQuote) {
+                               writer.write("\n\\noindent{}");
+                       }
+                       lastWasQuote = true;
+                       break;
+               case IMAGE:
+                       // TODO what about images in LaTeX?
+                       break;
+               }
+       }
+
+       private String latexEncode(String input) {
+               StringBuilder builder = new StringBuilder();
+               for (char car : input.toCharArray()) {
+                       // TODO: check restricted chars?
+                       if (car == '^' || car == '$' || car == '\\' || car == '#'
+                                       || car == '%') {
+                               builder.append('\\');
+                               builder.append(car);
+                       } else if (car == openQuote) {
+                               builder.append('`');
+                       } else if (car == closeQuote) {
+                               builder.append('\'');
+                       } else if (car == openDoubleQuote) {
+                               builder.append("``");
+                       } else if (car == closeDoubleQuote) {
+                               builder.append("''");
+                       } else {
+                               builder.append(car);
+                       }
+               }
+
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Sysout.java b/src/be/nikiroo/fanfix/output/Sysout.java
new file mode 100644 (file)
index 0000000..f6cd789
--- /dev/null
@@ -0,0 +1,22 @@
+package be.nikiroo.fanfix.output;
+
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+
+class Sysout extends BasicOutput {
+       @Override
+       protected void writeStoryHeader(Story story) {
+               System.out.println(story);
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) {
+               System.out.println(chap);
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) {
+               System.out.println(para);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/Text.java b/src/be/nikiroo/fanfix/output/Text.java
new file mode 100644 (file)
index 0000000..f0516dc
--- /dev/null
@@ -0,0 +1,133 @@
+package be.nikiroo.fanfix.output;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+
+class Text extends BasicOutput {
+       protected BufferedWriter writer;
+       protected File targetDir;
+       private boolean nextParaIsCover = true;
+
+       @Override
+       public File process(Story story, File targetDir, String targetName)
+                       throws IOException {
+               String targetNameOrig = targetName;
+               targetName += getDefaultExtension(false);
+
+               this.targetDir = targetDir;
+
+               File target = new File(targetDir, targetName);
+
+               writer = new BufferedWriter(new OutputStreamWriter(
+                               new FileOutputStream(target), "UTF-8"));
+               try {
+                       super.process(story, targetDir, targetNameOrig);
+               } finally {
+                       writer.close();
+                       writer = null;
+               }
+
+               return target;
+       }
+
+       @Override
+       public String getDefaultExtension(boolean readerTarget) {
+               return ".txt";
+       }
+
+       @Override
+       protected void writeStoryHeader(Story story) throws IOException {
+               String title = "";
+               String author = null;
+               String date = null;
+
+               MetaData meta = story.getMeta();
+               if (meta != null) {
+                       title = meta.getTitle() == null ? "" : meta.getTitle();
+                       author = meta.getAuthor();
+                       date = meta.getDate();
+               }
+
+               writer.write(title);
+               writer.write("\n");
+               if (author != null && !author.isEmpty()) {
+                       writer.write(Instance.getInstance().getTrans().getString(StringId.BY) + " " + author);
+               }
+               if (date != null && !date.isEmpty()) {
+                       writer.write(" (");
+                       writer.write(date);
+                       writer.write(")");
+               }
+               writer.write("\n");
+
+               // resume:
+               if (meta != null && meta.getResume() != null) {
+                       writeChapter(meta.getResume());
+               }
+       }
+
+       @Override
+       protected void writeChapterHeader(Chapter chap) throws IOException {
+               String txt;
+               if (chap.getName() != null && !chap.getName().isEmpty()) {
+                       txt = Instance.getInstance().getTrans().getString(StringId.CHAPTER_NAMED, chap.getNumber(), chap.getName());
+               } else {
+                       txt = Instance.getInstance().getTrans().getString(StringId.CHAPTER_UNNAMED, chap.getNumber());
+               }
+
+               writer.write("\n" + txt + "\n");
+               for (int i = 0; i < txt.length(); i++) {
+                       writer.write("—");
+               }
+               writer.write("\n\n");
+       }
+
+       @Override
+       protected void writeParagraphFooter(Paragraph para) throws IOException {
+               writer.write("\n");
+       }
+
+       @Override
+       protected void writeParagraphHeader(Paragraph para) throws IOException {
+               if (para.getType() == ParagraphType.IMAGE) {
+                       File file = new File(targetDir, getCurrentImageBestName(true));
+                       try {
+                               Instance.getInstance().getCache().saveAsImage(para.getContentImage(), file, nextParaIsCover);
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(new IOException("Cannot save an image", e));
+                       }
+               }
+
+               nextParaIsCover = false;
+       }
+
+       @Override
+       protected void writeTextLine(ParagraphType type, String line)
+                       throws IOException {
+               switch (type) {
+               case BLANK:
+                       break;
+               case BREAK:
+                       writer.write("\n* * *\n");
+                       break;
+               case NORMAL:
+               case QUOTE:
+                       writer.write(line);
+                       break;
+               case IMAGE:
+                       writer.write("[" + getCurrentImageBestName(true) + "]");
+                       break;
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/output/epub.style.css b/src/be/nikiroo/fanfix/output/epub.style.css
new file mode 100644 (file)
index 0000000..2c4a961
--- /dev/null
@@ -0,0 +1,110 @@
+html {
+       text-align: justify;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       NO_text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+}
+
+.normal {
+       /* Can be removed if you want a more "compact" view */
+       display: block;
+}
+
+.blank {
+       /* Can be removed if you want a more "compact" view */
+       height: 24px;
+       width: 100%;
+}
+
+hr.break {
+       /* Can be removed if you want a more "compact" view */
+       margin-top: 48px;
+       margin-bottom: 48px;
+}
+
+.dialogues {
+}
+
+.dialogue {
+       font-style: italic;
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/output/html.style.css b/src/be/nikiroo/fanfix/output/html.style.css
new file mode 100644 (file)
index 0000000..6b6d0d2
--- /dev/null
@@ -0,0 +1,112 @@
+html {
+       text-align: justify;
+       max-width: 800px;
+       margin: auto;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       NO_text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+}
+
+.normal {
+       /* Can be removed if you want a more "compact" view */
+       display: block;
+}
+
+.blank {
+       /* Can be removed if you want a more "compact" view */
+       height: 24px;
+       width: 100%;
+}
+
+hr.break {
+       /* Can be removed if you want a more "compact" view */
+       margin-top: 48px;
+       margin-bottom: 48px;
+}
+
+.dialogues {
+}
+
+.dialogue {
+       font-style: italic;
+}
diff --git a/src/be/nikiroo/fanfix/output/package-info.java b/src/be/nikiroo/fanfix/output/package-info.java
new file mode 100644 (file)
index 0000000..8314c80
--- /dev/null
@@ -0,0 +1,12 @@
+/**
+ * This package contains all the output processors.
+ * <p>
+ * Of those, only {@link be.nikiroo.fanfix.output.BasicOutput} is public,
+ * but it contains a method 
+ * ({@link be.nikiroo.fanfix.output.BasicOutput#getOutput(be.nikiroo.fanfix.output.BasicOutput.OutputType, boolean,boolean)})
+ * to get all the other 
+ * {@link be.nikiroo.fanfix.output.BasicOutput.OutputType}s.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.output;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/package-info.java b/src/be/nikiroo/fanfix/package-info.java
new file mode 100644 (file)
index 0000000..cfd9cbe
--- /dev/null
@@ -0,0 +1,11 @@
+/**
+ * Fanfix is a program that can support a few different websites from 
+ * which to retrieve stories, then process them into <tt>epub</tt> (or other)
+ * files that you can read anywhere.
+ * <p>
+ * It has support for a {@link be.nikiroo.fanfix.library.BasicLibrary} system, 
+ * too, and can even offer its services over the network.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/reader/BasicReader.java b/src/be/nikiroo/fanfix/reader/BasicReader.java
new file mode 100644 (file)
index 0000000..9ec0879
--- /dev/null
@@ -0,0 +1,231 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Map;
+import java.util.TreeMap;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * The class that handles the different {@link Story} readers you can use.
+ * 
+ * @author niki
+ */
+public abstract class BasicReader {
+       /**
+        * Return an {@link URL} from this {@link String}, be it a file path or an
+        * actual {@link URL}.
+        * 
+        * @param sourceString
+        *            the source
+        * 
+        * @return the corresponding {@link URL}
+        * 
+        * @throws MalformedURLException
+        *             if this is neither a file nor a conventional {@link URL}
+        */
+       public static URL getUrl(String sourceString) throws MalformedURLException {
+               if (sourceString == null || sourceString.isEmpty()) {
+                       throw new MalformedURLException("Empty url");
+               }
+
+               URL source = null;
+               try {
+                       source = new URL(sourceString);
+               } catch (MalformedURLException e) {
+                       File sourceFile = new File(sourceString);
+                       source = sourceFile.toURI().toURL();
+               }
+
+               return source;
+       }
+
+       /**
+        * Describe a {@link Story} from its {@link MetaData} and return a list of
+        * title/value that represent this {@link Story}.
+        * 
+        * @param meta
+        *            the {@link MetaData} to represent
+        * 
+        * @return the information
+        */
+       public static Map<String, String> getMetaDesc(MetaData meta) {
+               Map<String, String> metaDesc = new TreeMap<String, String>();
+
+               // TODO: i18n
+
+               StringBuilder tags = new StringBuilder();
+               for (String tag : meta.getTags()) {
+                       if (tags.length() > 0) {
+                               tags.append(", ");
+                       }
+                       tags.append(tag);
+               }
+
+               // TODO: i18n
+               metaDesc.put("Author", meta.getAuthor());
+               metaDesc.put("Publication date", formatDate(meta.getDate()));
+               metaDesc.put("Published on", meta.getPublisher());
+               metaDesc.put("URL", meta.getUrl());
+               String count = "";
+               if (meta.getWords() > 0) {
+                       count = StringUtils.formatNumber(meta.getWords());
+               }
+               if (meta.isImageDocument()) {
+                       metaDesc.put("Number of images", count);
+               } else {
+                       metaDesc.put("Number of words", count);
+               }
+               metaDesc.put("Source", meta.getSource());
+               metaDesc.put("Subject", meta.getSubject());
+               metaDesc.put("Language", meta.getLang());
+               metaDesc.put("Tags", tags.toString());
+
+               return metaDesc;
+       }
+
+       /**
+        * Open the {@link Story} with an external reader (the program will be
+        * passed the main file associated with this {@link Story}).
+        * 
+        * @param lib
+        *            the {@link BasicLibrary} to select the {@link Story} from
+        * @param luid
+        *            the {@link Story} LUID
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public void openExternal(BasicLibrary lib, String luid, boolean sync)
+                       throws IOException {
+               MetaData meta = lib.getInfo(luid);
+               File target = lib.getFile(luid, null);
+
+               openExternal(meta, target, sync);
+       }
+
+       /**
+        * Open the {@link Story} with an external reader (the program will be
+        * passed the given target file).
+        * 
+        * @param meta
+        *            the {@link Story} to load
+        * @param target
+        *            the target {@link File}
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void openExternal(MetaData meta, File target, boolean sync)
+                       throws IOException {
+               String program = null;
+               if (meta.isImageDocument()) {
+                       program = Instance.getInstance().getUiConfig().getString(UiConfig.IMAGES_DOCUMENT_READER);
+               } else {
+                       program = Instance.getInstance().getUiConfig().getString(UiConfig.NON_IMAGES_DOCUMENT_READER);
+               }
+
+               if (program != null && program.trim().isEmpty()) {
+                       program = null;
+               }
+
+               start(target, program, sync);
+       }
+
+       /**
+        * Start a file and open it with the given program if given or the first
+        * default system starter we can find.
+        * 
+        * @param target
+        *            the target to open
+        * @param program
+        *            the program to use or NULL for the default system starter
+        * @param sync
+        *            execute the process synchronously (wait until it is terminated
+        *            before returning)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void start(File target, String program, boolean sync)
+                       throws IOException {
+
+               Process proc = null;
+               if (program == null) {
+                       boolean ok = false;
+                       for (String starter : new String[] { "xdg-open", "open", "see",
+                                       "start", "run" }) {
+                               try {
+                                       Instance.getInstance().getTraceHandler().trace("starting external program");
+                                       proc = Runtime.getRuntime().exec(new String[] { starter, target.getAbsolutePath() });
+                                       ok = true;
+                                       break;
+                               } catch (IOException e) {
+                               }
+                       }
+                       if (!ok) {
+                               throw new IOException("Cannot find a program to start the file");
+                       }
+               } else {
+                       Instance.getInstance().getTraceHandler().trace("starting external program");
+                       proc = Runtime.getRuntime().exec(
+                                       new String[] { program, target.getAbsolutePath() });
+               }
+
+               if (proc != null && sync) {
+                       try {
+                               proc.waitFor();
+                       } catch (InterruptedException e) {
+                       }
+               }
+       }
+
+       static private String formatDate(String date) {
+               long ms = 0;
+
+               if (date != null && !date.isEmpty()) {
+                       try {
+                               ms = StringUtils.toTime(date);
+                       } catch (ParseException e) {
+                       }
+
+                       if (ms <= 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat(
+                                               "yyyy-MM-dd'T'HH:mm:ssSSS");
+                               try {
+                                       ms = sdf.parse(date).getTime();
+                               } catch (ParseException e) {
+                               }
+                       }
+
+                       if (ms > 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                               return sdf.format(new Date(ms));
+                       }
+               }
+
+               if (date == null) {
+                       date = "";
+               }
+
+               // :(
+               return date;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/CliReader.java b/src/be/nikiroo/fanfix/reader/CliReader.java
new file mode 100644 (file)
index 0000000..96ca644
--- /dev/null
@@ -0,0 +1,256 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.IOException;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.searchable.BasicSearchable;
+import be.nikiroo.fanfix.searchable.SearchableTag;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Command line {@link Story} reader.
+ * <p>
+ * Will output stories to the console.
+ * 
+ * @author niki
+ */
+public class CliReader extends BasicReader {
+       public void listBooks(String source) throws IOException {
+               List<MetaData> stories = Instance.getInstance().getLibrary().getList()
+                               .filter(source, null, null);
+
+               for (MetaData story : stories) {
+                       String author = "";
+                       if (story.getAuthor() != null && !story.getAuthor().isEmpty()) {
+                               author = " (" + story.getAuthor() + ")";
+                       }
+
+                       System.out.println(
+                                       story.getLuid() + ": " + story.getTitle() + author);
+               }
+       }
+
+       public void listChapters(Story story) throws IOException {
+               if (story == null || story.getMeta() == null) {
+                       throw new IOException("No story to read");
+               }
+               MetaData meta = story.getMeta();
+               if (meta == null) {
+                       throw new IOException("No story to read");
+               }
+
+               String title = "";
+               String author = "";
+
+               if (meta.getTitle() != null) {
+                       title = meta.getTitle();
+               }
+
+               if (meta.getAuthor() != null) {
+                       author = "©" + meta.getAuthor();
+                       if (meta.getDate() != null && !meta.getDate().isEmpty()) {
+                               author = author + " (" + meta.getDate() + ")";
+                       }
+               }
+
+               System.out.println(title);
+               System.out.println(author);
+               System.out.println("");
+
+               for (Chapter chap : story) {
+                       if (chap.getName() != null && !chap.getName().isEmpty()) {
+                               System.out.println(Instance.getInstance().getTrans().getString(
+                                               StringId.CHAPTER_NAMED, chap.getNumber(),
+                                               chap.getName()));
+                       } else {
+                               System.out.println(Instance.getInstance().getTrans()
+                                               .getString(StringId.CHAPTER_UNNAMED, chap.getNumber()));
+                       }
+               }
+       }
+
+       public void printChapter(Story story, int chapter) throws IOException {
+               if (story == null || story.getMeta() == null) {
+                       throw new IOException("No story to read");
+               }
+               MetaData meta = story.getMeta();
+               if (meta == null) {
+                       throw new IOException("No story to read");
+               }
+
+               if (chapter <= 0 || chapter > story.getChapters().size()) {
+                       System.err.println("Chapter " + chapter + ": no such chapter");
+               } else {
+                       Chapter chap = story.getChapters().get(chapter - 1);
+                       System.out.println(
+                                       "Chapter " + chap.getNumber() + ": " + chap.getName());
+                       System.out.println();
+                       
+                       for (Paragraph para : chap) {
+                               Image img = para.getContentImage();
+                               if (img != null) {
+                                       String sz = StringUtils.formatNumber(img.getSize(), 1);
+                                       System.out.println("[Image: " + sz + "]");
+                               } else {
+                                       System.out.println(
+                                                       para.getContent() == null ? "" : para.getContent());
+                               }
+                               System.out.println("");
+                       }
+               }
+       }
+
+       public void listSearchables() throws IOException {
+               for (SupportType type : SupportType.values()) {
+                       if (BasicSearchable.getSearchable(type) != null) {
+                               System.out.println(type);
+                       }
+               }
+       }
+
+       public void searchBooksByKeyword(SupportType searchOn, String keywords,
+                       int page, int item) throws IOException {
+               BasicSearchable search = BasicSearchable.getSearchable(searchOn);
+
+               if (page == 0) {
+                       System.out.println(search.searchPages(keywords));
+               } else {
+                       List<MetaData> metas = search.search(keywords, page);
+
+                       if (item == 0) {
+                               System.out.println(
+                                               "Page " + page + " of stories for: " + keywords);
+                               displayStories(metas);
+                       } else {
+                               // ! 1-based index !
+                               if (item <= 0 || item > metas.size()) {
+                                       throw new IOException("Index out of bounds: " + item);
+                               }
+
+                               MetaData meta = metas.get(item - 1);
+                               displayStory(meta);
+                       }
+               }
+       }
+
+       public void searchBooksByTag(SupportType searchOn, int page, int item,
+                       Integer... tags) throws IOException {
+
+               BasicSearchable search = BasicSearchable.getSearchable(searchOn);
+               SearchableTag stag = search.getTag(tags);
+
+               if (stag == null) {
+                       // TODO i18n
+                       System.out.println("Known tags: ");
+                       int i = 1;
+                       for (SearchableTag s : search.getTags()) {
+                               System.out.println(String.format("%d: %s", i, s.getName()));
+                               i++;
+                       }
+               } else {
+                       if (page <= 0) {
+                               if (stag.isLeaf()) {
+                                       System.out.println(search.searchPages(stag));
+                               } else {
+                                       System.out.println(stag.getCount());
+                               }
+                       } else {
+                               List<MetaData> metas = null;
+                               List<SearchableTag> subtags = null;
+                               int count;
+
+                               if (stag.isLeaf()) {
+                                       metas = search.search(stag, page);
+                                       count = metas.size();
+                               } else {
+                                       subtags = stag.getChildren();
+                                       count = subtags.size();
+                               }
+
+                               if (item > 0) {
+                                       if (item <= count) {
+                                               if (metas != null) {
+                                                       MetaData meta = metas.get(item - 1);
+                                                       displayStory(meta);
+                                               } else {
+                                                       SearchableTag subtag = subtags.get(item - 1);
+                                                       displayTag(subtag);
+                                               }
+                                       } else {
+                                               System.out.println(
+                                                               "Invalid item: only " + count + " items found");
+                                       }
+                               } else {
+                                       if (metas != null) {
+                                               // TODO i18n
+                                               System.out.println(String.format("Content of %s: ",
+                                                               stag.getFqName()));
+                                               displayStories(metas);
+                                       } else {
+                                               // TODO i18n
+                                               System.out.println(String.format("Subtags of %s: ",
+                                                               stag.getFqName()));
+                                               displayTags(subtags);
+                                       }
+                               }
+                       }
+               }
+       }
+
+       private void displayTag(SearchableTag subtag) {
+               // TODO: i18n
+               String stories = "stories";
+               String num = StringUtils.formatNumber(subtag.getCount());
+               System.out.println(String.format("%s (%s), %s %s", subtag.getName(),
+                               subtag.getFqName(), num, stories));
+       }
+
+       private void displayStory(MetaData meta) {
+               System.out.println(meta.getTitle());
+               System.out.println();
+               System.out.println(meta.getUrl());
+               System.out.println();
+               System.out.println("Tags: " + meta.getTags());
+               System.out.println();
+               for (Paragraph para : meta.getResume()) {
+                       System.out.println(para.getContent());
+                       System.out.println("");
+               }
+       }
+
+       private void displayTags(List<SearchableTag> subtags) {
+               int i = 1;
+               for (SearchableTag subtag : subtags) {
+                       String total = "";
+                       if (subtag.getCount() > 0) {
+                               total = StringUtils.formatNumber(subtag.getCount());
+                       }
+
+                       if (total.isEmpty()) {
+                               System.out
+                                               .println(String.format("%d: %s", i, subtag.getName()));
+                       } else {
+                               System.out.println(String.format("%d: %s (%s)", i,
+                                               subtag.getName(), total));
+                       }
+
+                       i++;
+               }
+       }
+
+       private void displayStories(List<MetaData> metas) {
+               int i = 1;
+               for (MetaData meta : metas) {
+                       System.out.println(i + ": " + meta.getTitle());
+                       i++;
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/reader/TextOutput.java b/src/be/nikiroo/fanfix/reader/TextOutput.java
new file mode 100644 (file)
index 0000000..60b3a7f
--- /dev/null
@@ -0,0 +1,148 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+
+/**
+ * This class can export a chapter into HTML3 code ready for Java Swing support.
+ * 
+ * @author niki
+ */
+public class TextOutput {
+       private StringBuilder builder;
+       private BasicOutput output;
+       private Story fakeStory;
+       private boolean chapterName;
+
+       /**
+        * Create a new {@link TextOutput} that will convert a {@link Chapter} into
+        * HTML3 suited for Java Swing.
+        * 
+        * @param standalone
+        *            TRUE if you want a standalone document (with an <HTML> tag)
+        */
+       public TextOutput(final boolean standalone) {
+               builder = new StringBuilder();
+               fakeStory = new Story();
+
+               output = new BasicOutput() {
+                       private boolean paraInQuote;
+
+                       @Override
+                       protected void writeChapterHeader(Chapter chap) throws IOException {
+                               if (standalone) {
+                                       builder.append("<HTML style='line-height: 5px;'>");
+                               }
+
+                               if (chapterName) {
+                                       builder.append("<H1>");
+                                       builder.append("Chapter ");
+                                       builder.append(chap.getNumber());
+                                       if (chap.getName() != null
+                                                       && !chap.getName().trim().isEmpty()) {
+                                               builder.append(": ");
+                                               builder.append(chap.getName());
+                                       }
+                                       builder.append("</H1>");
+                               }
+
+                               builder.append("<DIV align='justify'>");
+                       }
+
+                       @Override
+                       protected void writeChapterFooter(Chapter chap) throws IOException {
+                               if (paraInQuote) {
+                                       builder.append("</DIV>");
+                               }
+                               paraInQuote = false;
+
+                               builder.append("</DIV>");
+
+                               if (standalone) {
+                                       builder.append("</HTML>");
+                               }
+                       }
+
+                       @Override
+                       protected void writeParagraph(Paragraph para) throws IOException {
+                               if ((para.getType() == ParagraphType.QUOTE) == !paraInQuote) {
+                                       paraInQuote = !paraInQuote;
+                                       if (paraInQuote) {
+                                               builder.append("<BR>");
+                                               builder.append("<DIV>");
+                                       } else {
+                                               builder.append("</DIV>");
+                                               builder.append("<BR>");
+                                       }
+                               }
+
+                               switch (para.getType()) {
+                               case NORMAL:
+                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+                                       builder.append(decorateText(para.getContent()));
+                                       builder.append("<BR>");
+                                       break;
+                               case BLANK:
+                                       builder.append("<FONT SIZE='1'><BR></FONT>");
+                                       break;
+                               case BREAK:
+                                       // Used to be 7777DD
+                                       builder.append("<P COLOR='#AAAAAA' ALIGN='CENTER'>");
+                                       builder.append("<FONT SIZE='5'>* * *</FONT>");
+                                       builder.append("</P>");
+                                       builder.append("<BR>");
+                                       break;
+                               case QUOTE:
+                                       builder.append("<DIV>");
+                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+                                       builder.append("&mdash;&nbsp;");
+                                       builder.append(decorateText(para.getContent()));
+                                       builder.append("</DIV>");
+
+                                       break;
+                               case IMAGE:
+                               }
+                       }
+
+                       @Override
+                       protected String enbold(String word) {
+                               // Used to be COLOR='#7777DD'
+                               return "<B>" + word + "</B>";
+                       }
+
+                       @Override
+                       protected String italize(String word) {
+                               return "<I COLOR='GRAY'>" + word + "</I>";
+                       }
+               };
+       }
+
+       /**
+        * Convert the chapter into HTML3 code.
+        * 
+        * @param chap
+        *            the {@link Chapter} to convert
+        * @param chapterName
+        *            display the chapter name
+        * 
+        * @return HTML3 code tested with Java Swing
+        */
+       public String convert(Chapter chap, boolean chapterName) {
+               this.chapterName = chapterName;
+               builder.setLength(0);
+               try {
+                       fakeStory.setChapters(Arrays.asList(chap));
+                       output.process(fakeStory, null, null);
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/BasicSearchable.java b/src/be/nikiroo/fanfix/searchable/BasicSearchable.java
new file mode 100644 (file)
index 0000000..cb0b171
--- /dev/null
@@ -0,0 +1,275 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.SupportType;
+
+/**
+ * This class supports browsing through stories on the supported websites. It
+ * will fetch some {@link MetaData} that satisfy a search query or some tags if
+ * supported.
+ * 
+ * @author niki
+ */
+public abstract class BasicSearchable {
+       private SupportType type;
+       private BasicSupport support;
+
+       /**
+        * Create a new {@link BasicSearchable} of the given type.
+        * 
+        * @param type
+        *            the type, must not be NULL
+        */
+       public BasicSearchable(SupportType type) {
+               setType(type);
+               support = BasicSupport.getSupport(getType(), null);
+       }
+
+       /**
+        * Find the given tag by its hierarchical IDs.
+        * <p>
+        * I.E., it will take the tag A, subtag B, subsubtag C...
+        * 
+        * @param ids
+        *            the IDs to look for
+        * 
+        * @return the appropriate tag fully filled, or NULL if not found
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public SearchableTag getTag(Integer... ids) throws IOException {
+               SearchableTag tag = null;
+               List<SearchableTag> tags = getTags();
+
+               for (Integer tagIndex : ids) {
+                       // ! 1-based index !
+                       if (tagIndex == null || tags == null || tagIndex <= 0
+                                       || tagIndex > tags.size()) {
+                               return null;
+                       }
+
+                       tag = tags.get(tagIndex - 1);
+                       fillTag(tag);
+                       tags = tag.getChildren();
+               }
+
+               return tag;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @return the type
+        */
+       public SupportType getType() {
+               return type;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @param type
+        *            the new type
+        */
+       protected void setType(SupportType type) {
+               this.type = type;
+       }
+
+       /**
+        * The associated {@link BasicSupport}.
+        * <p>
+        * Mostly used to download content.
+        * 
+        * @return the support
+        */
+       protected BasicSupport getSupport() {
+               return support;
+       }
+
+       /**
+        * Get a list of tags that can be browsed here.
+        * 
+        * @return the list of tags
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract public List<SearchableTag> getTags() throws IOException;
+
+       /**
+        * Fill the tag (set it 'complete') with more information from the support.
+        * 
+        * @param tag
+        *            the tag to fill
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract public void fillTag(SearchableTag tag) throws IOException;
+
+       /**
+        * Search for the given term and return the number of pages of results of
+        * stories satisfying this search term.
+        * 
+        * @param search
+        *            the term to search for
+        * 
+        * @return a number of pages
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract public int searchPages(String search) throws IOException;
+
+       /**
+        * Search for the given tag and return the number of pages of results of
+        * stories satisfying this tag.
+        * 
+        * @param tag
+        *            the tag to search for
+        * 
+        * @return a number of pages
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract public int searchPages(SearchableTag tag) throws IOException;
+
+       /**
+        * Search for the given term and return a list of stories satisfying this
+        * search term.
+        * <p>
+        * Not that the returned stories will <b>NOT</b> be complete, but will only
+        * contain enough information to present them to the user and retrieve them.
+        * <p>
+        * URL is guaranteed to be usable, LUID will always be NULL.
+        * 
+        * @param search
+        *            the term to search for
+        * @param page
+        *            the page to use for result pagination, index is 1-based
+        * 
+        * @return a list of stories that satisfy that search term
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract public List<MetaData> search(String search, int page)
+                       throws IOException;
+
+       /**
+        * Search for the given tag and return a list of stories satisfying this
+        * tag.
+        * <p>
+        * Not that the returned stories will <b>NOT</b> be complete, but will only
+        * contain enough information to present them to the user and retrieve them.
+        * <p>
+        * URL is guaranteed to be usable, LUID will always be NULL.
+        * 
+        * @param tag
+        *            the tag to search for
+        * @param page
+        *            the page to use for result pagination (see
+        *            {@link SearchableTag#getPages()}, remember to check for -1),
+        *            index is 1-based
+        * 
+        * @return a list of stories that satisfy that search term
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       abstract public List<MetaData> search(SearchableTag tag, int page)
+                       throws IOException;
+
+       /**
+        * Load a document from its url.
+        * 
+        * @param url
+        *            the URL to load
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the document
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Document load(String url, boolean stable) throws IOException {
+               return load(new URL(url), stable);
+       }
+
+       /**
+        * Load a document from its url.
+        * 
+        * @param url
+        *            the URL to load
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the document
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Document load(URL url, boolean stable) throws IOException {
+               return DataUtil.load(Instance.getInstance().getCache().open(url, support, stable), "UTF-8", url.toString());
+       }
+
+       /**
+        * Return a {@link BasicSearchable} implementation supporting the given
+        * type, or NULL if it does not exist.
+        * 
+        * @param type
+        *            the type, can be NULL (will just return NULL, since we do not
+        *            support it)
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       static public BasicSearchable getSearchable(SupportType type) {
+               BasicSearchable support = null;
+
+               if (type != null) {
+                       switch (type) {
+                       case FIMFICTION:
+                               // TODO searchable for FIMFICTION
+                               break;
+                       case FANFICTION:
+                               support = new Fanfiction(type);
+                               break;
+                       case MANGAHUB:
+                               // TODO searchable for MANGAHUB
+                               break;
+                       case E621:
+                               // TODO searchable for E621
+                               break;
+                       case YIFFSTAR:
+                               // TODO searchable for YIFFSTAR
+                               break;
+                       case E_HENTAI:
+                               // TODO searchable for E_HENTAI
+                               break;
+                       case MANGA_LEL:
+                               support = new MangaLel();
+                               break;
+                       case CBZ:
+                       case HTML:
+                       case INFO_TEXT:
+                       case TEXT:
+                       case EPUB:
+                               break;
+                       }
+               }
+
+               return support;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/Fanfiction.java b/src/be/nikiroo/fanfix/searchable/Fanfiction.java
new file mode 100644 (file)
index 0000000..e2fba1f
--- /dev/null
@@ -0,0 +1,411 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * A {@link BasicSearchable} for Fanfiction.NET.
+ * 
+ * @author niki
+ */
+class Fanfiction extends BasicSearchable {
+       static private String BASE_URL = "http://fanfiction.net/";
+
+       /**
+        * Create a new {@link Fanfiction}.
+        * 
+        * @param type
+        *            {@link SupportType#FANFICTION}
+        */
+       public Fanfiction(SupportType type) {
+               super(type);
+       }
+
+       @Override
+       public List<SearchableTag> getTags() throws IOException {
+               String storiesName = null;
+               String crossoversName = null;
+               Map<String, String> stories = new HashMap<String, String>();
+               Map<String, String> crossovers = new HashMap<String, String>();
+
+               Document mainPage = load(BASE_URL, true);
+               Element menu = mainPage.getElementsByClass("dropdown").first();
+               if (menu != null) {
+                       Element ul = menu.getElementsByClass("dropdown-menu").first();
+                       if (ul != null) {
+                               Map<String, String> currentList = null;
+                               for (Element li : ul.getElementsByTag("li")) {
+                                       if (li.hasClass("disabled")) {
+                                               if (storiesName == null) {
+                                                       storiesName = li.text();
+                                                       currentList = stories;
+                                               } else {
+                                                       crossoversName = li.text();
+                                                       currentList = crossovers;
+                                               }
+                                       } else if (currentList != null) {
+                                               Element a = li.getElementsByTag("a").first();
+                                               if (a != null) {
+                                                       currentList.put(a.absUrl("href"), a.text());
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               List<SearchableTag> tags = new ArrayList<SearchableTag>();
+
+               if (storiesName != null) {
+                       SearchableTag tag = new SearchableTag(null, storiesName, false);
+                       for (String id : stories.keySet()) {
+                               tag.add(new SearchableTag(id, stories.get(id), false, false));
+                       }
+                       tags.add(tag);
+               }
+
+               if (crossoversName != null) {
+                       SearchableTag tag = new SearchableTag(null, crossoversName, false);
+                       for (String id : crossovers.keySet()) {
+                               tag.add(new SearchableTag(id, crossovers.get(id), false, false));
+                       }
+                       tags.add(tag);
+               }
+
+               return tags;
+       }
+
+       @Override
+       public void fillTag(SearchableTag tag) throws IOException {
+               if (tag.getId() == null || tag.isComplete()) {
+                       return;
+               }
+
+               Document doc = load(tag.getId(), false);
+               Element list = doc.getElementById("list_output");
+               if (list != null) {
+                       Element table = list.getElementsByTag("table").first();
+                       if (table != null) {
+                               for (Element div : table.getElementsByTag("div")) {
+                                       Element a = div.getElementsByTag("a").first();
+                                       Element span = div.getElementsByTag("span").first();
+
+                                       if (a != null) {
+                                               String subid = a.absUrl("href");
+                                               boolean crossoverSubtag = subid
+                                                               .contains("/crossovers/");
+
+                                               SearchableTag subtag = new SearchableTag(subid,
+                                                               a.text(), !crossoverSubtag, !crossoverSubtag);
+
+                                               tag.add(subtag);
+                                               if (span != null) {
+                                                       String nr = span.text();
+                                                       if (nr.startsWith("(")) {
+                                                               nr = nr.substring(1);
+                                                       }
+                                                       if (nr.endsWith(")")) {
+                                                               nr = nr.substring(0, nr.length() - 1);
+                                                       }
+                                                       nr = nr.trim();
+
+                                                       // TODO: fix toNumber/fromNumber
+                                                       nr = nr.replaceAll("\\.[0-9]*", "");
+
+                                                       subtag.setCount(StringUtils.toNumber(nr));
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               tag.setComplete(true);
+       }
+
+       @Override
+       public List<MetaData> search(String search, int page) throws IOException {
+               String encoded = URLEncoder.encode(search.toLowerCase(), "utf-8");
+               String url = BASE_URL + "search/?ready=1&type=story&keywords="
+                               + encoded + "&ppage=" + page;
+
+               return getStories(url, null, null);
+       }
+
+       @Override
+       public List<MetaData> search(SearchableTag tag, int page)
+                       throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+
+               String url = tag.getId();
+               if (url != null) {
+                       if (page > 1) {
+                               int pos = url.indexOf("&p=");
+                               if (pos >= 0) {
+                                       url = url.replaceAll("(.*\\&p=)[0-9]*(.*)", "$1\\" + page
+                                                       + "$2");
+                               } else {
+                                       url += "&p=" + page;
+                               }
+                       }
+
+                       Document doc = load(url, false);
+
+                       // Update the pages number if needed
+                       if (tag.getPages() < 0 && tag.isLeaf()) {
+                               tag.setPages(getPages(doc));
+                       }
+
+                       // Find out the full subjects (including parents)
+                       String subjects = "";
+                       for (SearchableTag t = tag; t != null; t = t.getParent()) {
+                               if (!subjects.isEmpty()) {
+                                       subjects += ", ";
+                               }
+                               subjects += t.getName();
+                       }
+
+                       metas = getStories(url, doc, subjects);
+               }
+
+               return metas;
+       }
+
+       @Override
+       public int searchPages(String search) throws IOException {
+               String encoded = URLEncoder.encode(search.toLowerCase(), "utf-8");
+               String url = BASE_URL + "search/?ready=1&type=story&keywords="
+                               + encoded;
+
+               return getPages(load(url, false));
+       }
+
+       @Override
+       public int searchPages(SearchableTag tag) throws IOException {
+               if (tag.isLeaf()) {
+                       String url = tag.getId();
+                       return getPages(load(url, false));
+               }
+
+               return 0;
+       }
+
+       /**
+        * Return the number of pages in this stories result listing.
+        * 
+        * @param doc
+        *            the document
+        * 
+        * @return the number of pages or -1 if unknown
+        */
+       private int getPages(Document doc) {
+               int pages = -1;
+
+               if (doc != null) {
+                       Element center = doc.getElementsByTag("center").first();
+                       if (center != null) {
+                               for (Element a : center.getElementsByTag("a")) {
+                                       if (a.absUrl("href").contains("&p=")) {
+                                               int thisLinkPages = -1;
+                                               try {
+                                                       String[] tab = a.absUrl("href").split("=");
+                                                       tab = tab[tab.length - 1].split("&");
+                                                       thisLinkPages = Integer
+                                                                       .parseInt(tab[tab.length - 1]);
+                                               } catch (Exception e) {
+                                               }
+
+                                               pages = Math.max(pages, thisLinkPages);
+                                       }
+                               }
+                       }
+               }
+
+               return pages;
+       }
+
+       /**
+        * Fetch the stories from the given page.
+        * 
+        * @param sourceUrl
+        *            the url of the document
+        * @param doc
+        *            the document to use (if NULL, will be loaded from
+        *            <tt>sourceUrl</tt>)
+        * @param mainSubject
+        *            the main subject (the anime/book/movie item related to the
+        *            stories, like "MLP" or "Doctor Who"), or NULL if none
+        * 
+        * @return the stories found in it
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       private List<MetaData> getStories(String sourceUrl, Document doc,
+                       String mainSubject) throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+
+               if (doc == null) {
+                       doc = load(sourceUrl, false);
+               }
+
+               for (Element story : doc.getElementsByClass("z-list")) {
+                       MetaData meta = new MetaData();
+                       meta.setImageDocument(false);
+                       meta.setSource(getType().getSourceName());
+                       meta.setPublisher(getType().getSourceName());
+                       meta.setType(getType().toString());
+
+                       // Title, URL, Cover
+                       Element stitle = story.getElementsByClass("stitle").first();
+                       if (stitle != null) {
+                               meta.setTitle(stitle.text());
+                               meta.setUrl(stitle.absUrl("href"));
+                               meta.setUuid(meta.getUrl());
+                               Element cover = stitle.getElementsByTag("img").first();
+                               if (cover != null) {
+                                       // note: see data-original if needed?
+                                       String coverUrl = cover.absUrl("src");
+
+                                       try {
+                                               InputStream in = Instance.getInstance().getCache().open(new URL(coverUrl), getSupport(), true);
+                                               try {
+                                                       meta.setCover(new Image(in));
+                                               } finally {
+                                                       in.close();
+                                               }
+                                       } catch (Exception e) {
+                                               // Should not happen on Fanfiction.net
+                                               Instance.getInstance().getTraceHandler().error(new Exception(
+                                                               "Cannot download cover for Fanfiction story in search mode: " + meta.getTitle(), e));
+                                       }
+                               }
+                       }
+
+                       // Author
+                       Elements as = story.getElementsByTag("a");
+                       if (as.size() > 1) {
+                               meta.setAuthor(as.get(1).text());
+                       }
+
+                       // Tags (concatenated text), published date, updated date, Resume
+                       String tags = "";
+                       List<String> tagList = new ArrayList<String>();
+                       Elements divs = story.getElementsByTag("div");
+                       if (divs.size() > 1 && divs.get(1).childNodeSize() > 0) {
+                               String resume = divs.get(1).text();
+                               if (divs.size() > 2) {
+                                       tags = divs.get(2).text();
+                                       resume = resume.substring(0,
+                                                       resume.length() - tags.length()).trim();
+
+                                       for (Element d : divs.get(2).getElementsByAttribute(
+                                                       "data-xutime")) {
+                                               String secs = d.attr("data-xutime");
+                                               try {
+                                                       String date = new SimpleDateFormat("yyyy-MM-dd")
+                                                                       .format(new Date(
+                                                                                       Long.parseLong(secs) * 1000));
+                                                       // (updated, ) published
+                                                       if (meta.getDate() != null) {
+                                                               tagList.add("Updated: " + meta.getDate());
+                                                       }
+                                                       meta.setDate(date);
+                                               } catch (Exception e) {
+                                               }
+                                       }
+                               }
+
+                               meta.setResume(getSupport().makeChapter(new URL(sourceUrl), 0,
+                                               Instance.getInstance().getTrans().getString(StringId.DESCRIPTION), resume));
+                       }
+
+                       // How are the tags ordered?
+                       // We have "Rated: xx", then the language, then all other tags
+                       // If the subject(s) is/are present, they are before "Rated: xx"
+
+                       // ////////////
+                       // Examples: //
+                       // ////////////
+
+                       // Search (Luna) Tags: [Harry Potter, Rated: T, English, Chapters:
+                       // 1, Words: 270, Reviews: 2, Published: 2/19/2013, Luna L.]
+
+                       // Normal (MLP) Tags: [Rated: T, Spanish, Drama/Suspense, Chapters:
+                       // 2, Words: 8,686, Reviews: 1, Favs: 1, Follows: 1, Updated: 4/7,
+                       // Published: 4/2]
+
+                       // Crossover (MLP/Who) Tags: [Rated: K+, English, Adventure/Romance,
+                       // Chapters: 8, Words: 7,788, Reviews: 2, Favs: 2, Follows: 1,
+                       // Published: 9/1/2016]
+
+                       boolean rated = false;
+                       boolean isLang = false;
+                       String subject = mainSubject == null ? "" : mainSubject;
+                       String[] tab = tags.split("  *-  *");
+                       for (int i = 0; i < tab.length; i++) {
+                               String tag = tab[i];
+                               if (tag.startsWith("Rated: ")) {
+                                       rated = true;
+                               }
+
+                               if (!rated) {
+                                       if (!subject.isEmpty()) {
+                                               subject += ", ";
+                                       }
+                                       subject += tag;
+                               } else if (isLang) {
+                                       meta.setLang(tag);
+                                       isLang = false;
+                               } else {
+                                       if (tag.contains(":")) {
+                                               // Handle special tags:
+                                               if (tag.startsWith("Words: ")) {
+                                                       try {
+                                                               meta.setWords(Long.parseLong(tag
+                                                                               .substring("Words: ".length())
+                                                                               .replace(",", "").trim()));
+                                                       } catch (Exception e) {
+                                                       }
+                                               } else if (tag.startsWith("Rated: ")) {
+                                                       tagList.add(tag);
+                                               }
+                                       } else {
+                                               // Normal tags are "/"-separated
+                                               for (String t : tag.split("/")) {
+                                                       tagList.add(t);
+                                               }
+                                       }
+
+                                       if (tag.startsWith("Rated: ")) {
+                                               isLang = true;
+                                       }
+                               }
+                       }
+
+                       meta.setSubject(subject);
+                       meta.setTags(tagList);
+
+                       metas.add(meta);
+               }
+
+               return metas;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/MangaLel.java b/src/be/nikiroo/fanfix/searchable/MangaLel.java
new file mode 100644 (file)
index 0000000..5ba21a0
--- /dev/null
@@ -0,0 +1,184 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+class MangaLel extends BasicSearchable {
+       private String BASE_URL = "http://mangas-lecture-en-ligne.fr/index_lel.php";
+
+       public MangaLel() {
+               super(SupportType.MANGA_LEL);
+       }
+
+       @Override
+       public List<SearchableTag> getTags() throws IOException {
+               List<SearchableTag> tags = new ArrayList<SearchableTag>();
+
+               String url = BASE_URL + "?page=recherche";
+               Document doc = load(url, false);
+
+               Element genre = doc.getElementsByClass("genre").first();
+               if (genre != null) {
+                       for (Element el : genre.getElementsByAttributeValueStarting("for",
+                                       "genre")) {
+                               tags.add(new SearchableTag(el.attr("for"), el.text(), true));
+                       }
+               }
+
+               return tags;
+       }
+
+       @Override
+       public void fillTag(SearchableTag tag) throws IOException {
+               // Tags are always complete
+       }
+
+       @Override
+       public List<MetaData> search(String search, int page) throws IOException {
+               String url = BASE_URL + "?nomProjet="
+                               + URLEncoder.encode(search, "utf-8")
+                               + "&nomAuteur=&nomTeam=&page=recherche&truc=truc";
+
+               // No pagination
+               return getResults(url);
+       }
+
+       @Override
+       public List<MetaData> search(SearchableTag tag, int page)
+                       throws IOException {
+               String url = BASE_URL + "?nomProjet=&nomAuteur=&nomTeam=&"
+                               + tag.getId() + "=on&page=recherche&truc=truc";
+
+               // No pagination
+               return getResults(url);
+       }
+
+       @Override
+       public int searchPages(String search) throws IOException {
+               // No pagination
+               return 1;
+       }
+
+       @Override
+       public int searchPages(SearchableTag tag) throws IOException {
+               if (tag.isLeaf()) {
+                       // No pagination
+                       return 1;
+               }
+
+               return 0;
+       }
+
+       private List<MetaData> getResults(String sourceUrl) throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+
+               Document doc = DataUtil.load(Instance.getInstance().getCache().open(new URL(sourceUrl), getSupport(), false),
+                               "UTF-8", sourceUrl);
+
+               for (Element result : doc.getElementsByClass("rechercheAffichage")) {
+                       Element a = result.getElementsByTag("a").first();
+                       if (a != null) {
+                               int projectId = -1;
+
+                               MetaData meta = new MetaData();
+
+                               // Target:
+                               // http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet=218
+
+                               // a.absUrl("href"):
+                               // http://mangas-lecture-en-ligne.fr/index_lel?onCommence=oui&idChapitre=2805
+
+                               // ...but we need the PROJECT id, not the CHAPTER id -> use
+                               // <IMG>
+
+                               Elements infos = result.getElementsByClass("texte");
+                               if (infos != null) {
+                                       String[] tab = infos.outerHtml().split("<br>");
+
+                                       meta.setLang("fr");
+                                       meta.setSource(getType().getSourceName());
+                                       meta.setPublisher(getType().getSourceName());
+                                       meta.setType(getType().toString());
+                                       meta.setSubject("manga");
+                                       meta.setImageDocument(true);
+                                       meta.setTitle(getVal(tab, 0));
+                                       meta.setAuthor(getVal(tab, 1));
+                                       meta.setTags(Arrays.asList(getVal(tab, 2).split(" ")));
+
+                                       meta.setResume(getSupport().makeChapter(new URL(sourceUrl), 0,
+                                                       Instance.getInstance().getTrans().getString(StringId.DESCRIPTION), getVal(tab, 5)));
+                               }
+
+                               Element img = result.getElementsByTag("img").first();
+                               if (img != null) {
+                                       try {
+                                               String[] tab = img.attr("src").split("/");
+                                               String str = tab[tab.length - 1];
+                                               tab = str.split("\\.");
+                                               str = tab[0];
+                                               projectId = Integer.parseInt(str);
+
+                                               String coverUrl = img.absUrl("src");
+                                               try {
+                                                       InputStream in = Instance.getInstance().getCache().open(new URL(coverUrl), getSupport(),
+                                                                       true);
+                                                       try {
+                                                               meta.setCover(new Image(in));
+                                                       } finally {
+                                                               in.close();
+                                                       }
+                                               } catch (Exception e) {
+                                                       // Happen often on MangaLEL...
+                                                       Instance.getInstance().getTraceHandler().trace(
+                                                                       "Cannot download cover for MangaLEL story in search mode: "     + meta.getTitle());
+                                               }
+                                       } catch (Exception e) {
+                                               // no project id... cannot use the story :(
+                                               Instance.getInstance().getTraceHandler()
+                                                               .error("Cannot find ProjectId for MangaLEL story in search mode: " + meta.getTitle());
+                                       }
+                               }
+
+                               if (projectId >= 0) {
+                                       meta.setUrl("http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet="
+                                                       + projectId);
+                                       meta.setUuid(meta.getUrl());
+                                       metas.add(meta);
+                               }
+                       }
+               }
+
+               return metas;
+       }
+
+       private String getVal(String[] tab, int i) {
+               String val = "";
+
+               if (i < tab.length) {
+                       val = StringUtils.unhtml(tab[i]);
+                       int pos = val.indexOf(":");
+                       if (pos >= 0) {
+                               val = val.substring(pos + 1).trim();
+                       }
+               }
+
+               return val;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/searchable/SearchableTag.java b/src/be/nikiroo/fanfix/searchable/SearchableTag.java
new file mode 100644 (file)
index 0000000..de86798
--- /dev/null
@@ -0,0 +1,324 @@
+package be.nikiroo.fanfix.searchable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class represents a tag that can be searched on a supported website.
+ * 
+ * @author niki
+ */
+public class SearchableTag {
+       private String id;
+       private String name;
+       private boolean complete;
+       private long count;
+
+       private SearchableTag parent;
+       private List<SearchableTag> children;
+
+       /**
+        * The number of stories result pages this tag can get.
+        * <p>
+        * We keep more information than what the getter/setter returns/accepts.
+        * <ul>
+        * <li>-2: this tag does not support stories results (not a leaf tag)</li>
+        * <li>-1: the number is not yet known, but will be known after a
+        * {@link BasicSearchable#fillTag(SearchableTag)} operation</li>
+        * <li>X: the number of pages</li>
+        * </ul>
+        */
+       private int pages;
+
+       /**
+        * Create a new {@link SearchableTag}.
+        * <p>
+        * Note that tags are complete by default.
+        * 
+        * @param id
+        *            the ID (usually a way to find the linked stories later on)
+        * @param name
+        *            the tag name, which can be displayed to the user
+        * @param leaf
+        *            the tag is a leaf tag, that is, it will not return subtags
+        *            with {@link BasicSearchable#fillTag(SearchableTag)} but will
+        *            return stories with
+        *            {@link BasicSearchable#search(SearchableTag, int)}
+        */
+       public SearchableTag(String id, String name, boolean leaf) {
+               this(id, name, leaf, true);
+       }
+
+       /**
+        * Create a new {@link SearchableTag}.
+        * 
+        * @param id
+        *            the ID (usually a way to find the linked stories later on)
+        * @param name
+        *            the tag name, which can be displayed to the user
+        * @param leaf
+        *            the tag is a leaf tag, that is, it will not return subtags
+        *            with {@link BasicSearchable#fillTag(SearchableTag)} but will
+        *            return stories with
+        *            {@link BasicSearchable#search(SearchableTag, int)}
+        * @param complete
+        *            the tag {@link SearchableTag#isComplete()} or not
+        */
+       public SearchableTag(String id, String name, boolean leaf, boolean complete) {
+               this.id = id;
+               this.name = name;
+               this.complete = leaf || complete;
+
+               setLeaf(leaf);
+
+               children = new ArrayList<SearchableTag>();
+       }
+
+       /**
+        * The ID (usually a way to find the linked stories later on).
+        * 
+        * @return the ID
+        */
+       public String getId() {
+               return id;
+       }
+
+       /**
+        * The tag name, which can be displayed to the user.
+        * 
+        * @return then name
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * The fully qualified tag name, which can be displayed to the user.
+        * <p>
+        * It will display all the tags that lead to this one as well as this one.
+        * 
+        * @return the fully qualified name
+        */
+       public String getFqName() {
+               if (parent != null) {
+                       return parent.getFqName() + " / " + name;
+               }
+
+               return "" + name;
+       }
+
+       /**
+        * Non-complete, non-leaf tags can still be completed via a
+        * {@link BasicSearchable#fillTag(SearchableTag)} operation from a
+        * {@link BasicSearchable}, in order to gain (more?) subtag children.
+        * <p>
+        * Leaf tags are always considered complete.
+        * 
+        * @return TRUE if it is complete
+        */
+       public boolean isComplete() {
+               return complete;
+       }
+
+       /**
+        * Non-complete, non-leaf tags can still be completed via a
+        * {@link BasicSearchable#fillTag(SearchableTag)} operation from a
+        * {@link BasicSearchable}, in order to gain (more?) subtag children.
+        * <p>
+        * Leaf tags are always considered complete.
+        * 
+        * @param complete
+        *            TRUE if it is complete
+        */
+       public void setComplete(boolean complete) {
+               this.complete = isLeaf() || complete;
+       }
+
+       /**
+        * The number of items that can be found with this tag if it is searched.
+        * <p>
+        * Will report the number of subtags by default.
+        * 
+        * @return the number of items
+        */
+       public long getCount() {
+               long count = this.count;
+               if (count <= 0) {
+                       count = children.size();
+               }
+
+               return count;
+       }
+
+       /**
+        * The number of items that can be found with this tag if it is searched.
+        * 
+        * @param count
+        *            the new count
+        */
+       public void setCount(long count) {
+               this.count = count;
+       }
+
+       /**
+        * The number of stories result pages this tag contains, only make sense if
+        * {@link SearchableTag#isLeaf()} returns TRUE.
+        * <p>
+        * Will return -1 if the number is not yet known.
+        * 
+        * @return the number of pages, or -1
+        */
+       public int getPages() {
+               return Math.max(-1, pages);
+       }
+
+       /**
+        * The number of stories result pages this tag contains, only make sense if
+        * {@link SearchableTag#isLeaf()} returns TRUE.
+        * 
+        * @param pages
+        *            the (positive or 0) number of pages
+        */
+       public void setPages(int pages) {
+               this.pages = Math.max(-1, pages);
+       }
+
+       /**
+        * This tag is a leaf tag, that is, it will not return other subtags with
+        * {@link BasicSearchable#fillTag(SearchableTag)} but will return stories
+        * with {@link BasicSearchable#search(SearchableTag, int)}.
+        * 
+        * @return TRUE if it is
+        */
+       public boolean isLeaf() {
+               return pages > -2;
+       }
+
+       /**
+        * This tag is a leaf tag, that is, it will not return other subtags with
+        * {@link BasicSearchable#fillTag(SearchableTag)} but will return stories
+        * with {@link BasicSearchable#search(SearchableTag, int)}.
+        * <p>
+        * Will reset the number of pages to -1.
+        * 
+        * @param leaf
+        *            TRUE if it is
+        */
+       public void setLeaf(boolean leaf) {
+               pages = leaf ? -1 : -2;
+               if (leaf) {
+                       complete = true;
+               }
+       }
+
+       /**
+        * The subtag children of this {@link SearchableTag}.
+        * <p>
+        * Never NULL.
+        * <p>
+        * Note that if {@link SearchableTag#isComplete()} returns false, you can
+        * still fill (more?) subtag children with a {@link BasicSearchable}.
+        * 
+        * @return the subtag children, never NULL
+        */
+       public List<SearchableTag> getChildren() {
+               return children;
+       }
+
+       /**
+        * Add the given {@link SearchableTag} as a subtag child.
+        * 
+        * @param tag
+        *            the tag to add
+        */
+       public void add(SearchableTag tag) {
+               if (tag == null) {
+                       throw new NullPointerException("tag");
+               }
+
+               for (SearchableTag p = this; p != null; p = p.parent) {
+                       if (p.equals(tag)) {
+                               throw new IllegalArgumentException(
+                                               "Tags do not allow recursion");
+                       }
+               }
+               for (SearchableTag p = tag; p != null; p = p.parent) {
+                       if (p.equals(this)) {
+                               throw new IllegalArgumentException(
+                                               "Tags do not allow recursion");
+                       }
+               }
+
+               children.add(tag);
+               tag.parent = this;
+       }
+
+       /**
+        * This {@link SearchableTag} parent tag, or NULL if none.
+        * 
+        * @return the parent or NULL
+        */
+       public SearchableTag getParent() {
+               return parent;
+       }
+
+       /**
+        * Display a DEBUG {@link String} representation of this object.
+        */
+       @Override
+       public String toString() {
+               String rep = name + " [" + id + "]";
+               if (!complete) {
+                       rep += "*";
+               }
+
+               if (getCount() > 0) {
+                       rep += " (" + getCount() + ")";
+               }
+
+               if (!children.isEmpty()) {
+                       String tags = "";
+                       int i = 1;
+                       for (SearchableTag tag : children) {
+                               if (!tags.isEmpty()) {
+                                       tags += ", ";
+                               }
+
+                               if (i > 10) {
+                                       tags += "...";
+                                       break;
+                               }
+
+                               tags += tag;
+                               i++;
+                       }
+
+                       rep += ": " + tags;
+               }
+
+               return rep;
+       }
+
+       @Override
+       public int hashCode() {
+               return getFqName().hashCode();
+       }
+
+       @Override
+       public boolean equals(Object otherObj) {
+               if (otherObj instanceof SearchableTag) {
+                       SearchableTag other = (SearchableTag) otherObj;
+                       if ((id == null && other.id == null)
+                                       || (id != null && id.equals(other.id))) {
+                               if (getFqName().equals(other.getFqName())) {
+                                       if ((parent == null && other.parent == null)
+                                                       || (parent != null && parent.equals(other.parent))) {
+                                               return true;
+                                       }
+                               }
+                       }
+               }
+
+               return false;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport.java b/src/be/nikiroo/fanfix/supported/BasicSupport.java
new file mode 100644 (file)
index 0000000..56a3bb8
--- /dev/null
@@ -0,0 +1,609 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Map.Entry;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * This class is the base class used by the other support classes. It can be
+ * used outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * <p>
+ * It will be used with 'resources' (usually web pages or files).
+ * 
+ * @author niki
+ */
+public abstract class BasicSupport {
+       private Document sourceNode;
+       private URL source;
+       private SupportType type;
+       private URL currentReferer; // with only one 'r', as in 'HTTP'...
+       
+       static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
+       static protected BasicSupportImages bsImages = new BasicSupportImages();
+       static protected BasicSupportPara bsPara = new BasicSupportPara(new BasicSupportHelper(), new BasicSupportImages());
+
+       /**
+        * Check if the given resource is supported by this {@link BasicSupport}.
+        * 
+        * @param url
+        *            the resource to check for
+        * 
+        * @return TRUE if it is
+        */
+       protected abstract boolean supports(URL url);
+
+       /**
+        * Return TRUE if the support will return HTML encoded content values for
+        * the chapters content.
+        * 
+        * @return TRUE for HTML
+        */
+       protected abstract boolean isHtml();
+
+       /**
+        * Return the {@link MetaData} of this story.
+        * 
+        * @return the associated {@link MetaData}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract MetaData getMeta() throws IOException;
+
+       /**
+        * Return the story description.
+        * 
+        * @return the description
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getDesc() throws IOException;
+
+       /**
+        * Return the list of chapters (name and resource).
+        * <p>
+        * Can be NULL if this {@link BasicSupport} do no use chapters.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the chapters or NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException;
+
+       /**
+        * Return the content of the chapter (possibly HTML encoded, if
+        * {@link BasicSupport#isHtml()} is TRUE).
+        * 
+        * @param chapUrl
+        *            the chapter {@link URL}
+        * @param number
+        *            the chapter number
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getChapterContent(URL chapUrl, int number,
+                       Progress pg) throws IOException;
+
+       /**
+        * Return the list of cookies (values included) that must be used to
+        * correctly fetch the resources.
+        * <p>
+        * You are expected to call the super method implementation if you override
+        * it.
+        * 
+        * @return the cookies
+        */
+       public Map<String, String> getCookies() {
+               return new HashMap<String, String>();
+       }
+
+       /**
+        * OAuth authorisation (aka, "bearer XXXXXXX").
+        * 
+        * @return the OAuth string
+        */
+       public String getOAuth() {
+               return null;
+       }
+
+       /**
+        * Return the canonical form of the main {@link URL}.
+        * 
+        * @param source
+        *            the source {@link URL}, which can be NULL
+        * 
+        * @return the canonical form of this {@link URL} or NULL if the source was
+        *         NULL
+        */
+       protected URL getCanonicalUrl(URL source) {
+               return source;
+       }
+
+       /**
+        * The main {@link Node} for this {@link Story}.
+        * 
+        * @return the node
+        */
+       protected Element getSourceNode() {
+               return sourceNode;
+       }
+
+       /**
+        * The main {@link URL} for this {@link Story}.
+        * 
+        * @return the URL
+        */
+       protected URL getSource() {
+               return source;
+       }
+
+       /**
+        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+        * the current {@link URL} we work on.
+        * 
+        * @return the referer
+        */
+       public URL getCurrentReferer() {
+               return currentReferer;
+       }
+
+       /**
+        * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
+        * the current {@link URL} we work on.
+        * 
+        * @param currentReferer
+        *            the new referer
+        */
+       protected void setCurrentReferer(URL currentReferer) {
+               this.currentReferer = currentReferer;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @return the type
+        */
+       public SupportType getType() {
+               return type;
+       }
+
+       /**
+        * The support type.
+        * 
+        * @param type
+        *            the new type
+        */
+       protected void setType(SupportType type) {
+               this.type = type;
+       }
+
+       /**
+        * Open an input link that will be used for the support.
+        * <p>
+        * Can return NULL, in which case you are supposed to work without a source
+        * node.
+        * 
+        * @param source
+        *            the source {@link URL}
+        * 
+        * @return the {@link InputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Document loadDocument(URL source) throws IOException {
+               String url = getCanonicalUrl(source).toString();
+               return DataUtil.load(Instance.getInstance().getCache().open(source, this, false), "UTF-8", url.toString());
+       }
+
+       /**
+        * Log into the support (can be a no-op depending upon the support).
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected void login() throws IOException {
+       }
+
+       /**
+        * Now that we have processed the {@link Story}, close the resources if any.
+        */
+       protected void close() {
+               setCurrentReferer(null);
+       }
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata.
+        * 
+        * @param getDesc
+        *            retrieve the description of the story, or not
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story processMeta(boolean getDesc, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               pg.setProgress(30);
+
+               Story story = new Story();
+               MetaData meta = getMeta();
+               if (meta.getCreationDate() == null
+                               || meta.getCreationDate().trim().isEmpty()) {
+                       meta.setCreationDate(bsHelper
+                                       .formatDate(StringUtils.fromTime(new Date().getTime())));
+               }
+               story.setMeta(meta);
+               pg.put("meta", meta);
+
+               pg.setProgress(50);
+
+               if (meta.getCover() == null) {
+                       meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
+               }
+
+               pg.setProgress(60);
+
+               if (getDesc) {
+                       String descChapterName = Instance.getInstance().getTrans().getString(StringId.DESCRIPTION);
+                       story.getMeta().setResume(bsPara.makeChapter(this, source, 0, descChapterName, //
+                                       getDesc(), isHtml(), null));
+               }
+
+               pg.done();
+               return story;
+       }
+
+       /**
+        * Utility method to convert the given URL into a JSON object.
+        * <p>
+        * Note that this method expects small JSON files (everything is copied into
+        * memory at least twice).
+        * 
+        * @param url
+        *            the URL to parse
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the JSON object
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected JSONObject getJson(String url, boolean stable)
+                       throws IOException {
+               try {
+                       return getJson(new URL(url), stable);
+               } catch (MalformedURLException e) {
+                       throw new IOException("Malformed URL: " + url, e);
+               }
+       }
+
+       /**
+        * Utility method to convert the given URL into a JSON object.
+        * <p>
+        * Note that this method expects small JSON files (everything is copied into
+        * memory at least twice).
+        * 
+        * @param url
+        *            the URL to parse
+        * @param stable
+        *            TRUE for more stable resources, FALSE when they often change
+        * 
+        * @return the JSON object
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected JSONObject getJson(URL url, boolean stable) throws IOException {
+               InputStream in = Instance.getInstance().getCache().open(url, null,
+                               stable);
+               try {
+                       Scanner scan = new Scanner(in);
+                       scan.useDelimiter("\0");
+                       try {
+                               return new JSONObject(scan.next());
+                       } catch (JSONException e) {
+                               throw new IOException(e);
+                       } finally {
+                               scan.close();
+                       }
+               } finally {
+                       in.close();
+               }
+       }
+
+       /**
+        * Process the given story resource into a fully filled {@link Story}
+        * object.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       // TODO: ADD final when BasicSupport_Deprecated is gone
+       public Story process(Progress pg) throws IOException {
+               setCurrentReferer(source);
+               login();
+               sourceNode = loadDocument(source);
+
+               try {
+                       Story story = doProcess(pg);
+                       
+                       // Check for "no chapters" stories
+                       if (story.getChapters().isEmpty()
+                                       && story.getMeta().getResume() != null
+                                       && !story.getMeta().getResume().getParagraphs().isEmpty()) {
+                               Chapter resume = story.getMeta().getResume();
+                               resume.setName("");
+                               resume.setNumber(1);
+                               story.getChapters().add(resume);
+                               story.getMeta().setWords(resume.getWords());
+
+                               String descChapterName = Instance.getInstance().getTrans()
+                                               .getString(StringId.DESCRIPTION);
+                               resume = new Chapter(0, descChapterName);
+                               story.getMeta().setResume(resume);
+                       }
+                       
+                       return story;
+               } finally {
+                       close();
+               }
+       }
+
+       /**
+        * Actual processing step, without the calls to other methods.
+        * <p>
+        * Will convert the story resource into a fully filled {@link Story} object.
+        * 
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story doProcess(Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+               
+               pg.setName("Initialising");
+
+               pg.setProgress(1);
+               Progress pgMeta = new Progress();
+               pg.addProgress(pgMeta, 10);
+               Story story = processMeta(true, pgMeta);
+               pgMeta.done(); // 10%
+               pg.put("meta", story.getMeta());
+
+               Progress pgGetChapters = new Progress();
+               pg.addProgress(pgGetChapters, 10);
+               story.setChapters(new ArrayList<Chapter>());
+               List<Entry<String, URL>> chapters = getChapters(pgGetChapters);
+               pgGetChapters.done(); // 20%
+               
+               if (chapters != null) {
+                       Progress pgChaps = new Progress("Extracting chapters", 0,
+                                       chapters.size() * 300);
+                       pg.addProgress(pgChaps, 80);
+
+                       long words = 0;
+                       int i = 1;
+                       for (Entry<String, URL> chap : chapters) {
+                               pgChaps.setName("Extracting chapter " + i);
+                               URL chapUrl = chap.getValue();
+                               String chapName = chap.getKey();
+                               if (chapUrl != null) {
+                                       setCurrentReferer(chapUrl);
+                               }
+
+                               pgChaps.setProgress(i * 100);
+                               Progress pgGetChapterContent = new Progress();
+                               Progress pgMakeChapter = new Progress();
+                               pgChaps.addProgress(pgGetChapterContent, 100);
+                               pgChaps.addProgress(pgMakeChapter, 100);
+
+                               String content = getChapterContent(chapUrl, i,
+                                               pgGetChapterContent);
+                               pgGetChapterContent.done();
+                               Chapter cc = bsPara.makeChapter(this, chapUrl, i,
+                                               chapName, content, isHtml(), pgMakeChapter);
+                               pgMakeChapter.done();
+
+                               words += cc.getWords();
+                               story.getChapters().add(cc);
+
+                               i++;
+                       }
+                       
+                       story.getMeta().setWords(words);
+
+                       pgChaps.setName("Extracting chapters");
+                       pgChaps.done();
+               }
+
+               pg.done();
+
+               return story;
+       }
+
+       /**
+        * Create a chapter from the given data.
+        * 
+        * @param source
+        *            the source URL for this content, which can be used to try and
+        *            find images if images are present in the format [image-url]
+        * @param number
+        *            the chapter number (0 = description)
+        * @param name
+        *            the chapter name
+        * @param content
+        *            the content of the chapter
+        *            
+        * @return the {@link Chapter}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Chapter makeChapter(URL source, int number, String name,
+                       String content) throws IOException {
+               return bsPara.makeChapter(this, source, number, name,
+                               content, isHtml(), null);
+       }
+
+       /**
+        * Return a {@link BasicSupport} implementation supporting the given
+        * resource if possible.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       public static BasicSupport getSupport(URL url) {
+               if (url == null) {
+                       return null;
+               }
+
+               // TEXT and INFO_TEXT always support files (not URLs though)
+               for (SupportType type : SupportType.values()) {
+                       if (type != SupportType.TEXT && type != SupportType.INFO_TEXT) {
+                               BasicSupport support = getSupport(type, url);
+                               if (support != null && support.supports(url)) {
+                                       return support;
+                               }
+                       }
+               }
+
+               for (SupportType type : new SupportType[] { SupportType.INFO_TEXT,
+                               SupportType.TEXT }) {
+                       BasicSupport support = getSupport(type, url);
+                       if (support != null && support.supports(url)) {
+                               return support;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return a {@link BasicSupport} implementation supporting the given type.
+        * 
+        * @param type
+        *            the type, must not be NULL
+        * @param url
+        *            the {@link URL} to support (can be NULL to get an
+        *            "abstract support"; if not NULL, will be used as the source
+        *            URL)
+        * 
+        * @return an implementation that supports it, or NULL
+        */
+       public static BasicSupport getSupport(SupportType type, URL url) {
+               BasicSupport support = null;
+
+               switch (type) {
+               case EPUB:
+                       support = new Epub();
+                       break;
+               case INFO_TEXT:
+                       support = new InfoText();
+                       break;
+               case FIMFICTION:
+                       try {
+                               // Can fail if no client key or NO in options
+                               support = new FimfictionApi();
+                       } catch (IOException e) {
+                               support = new Fimfiction();
+                       }
+                       break;
+               case FANFICTION:
+                       support = new Fanfiction();
+                       break;
+               case TEXT:
+                       support = new Text();
+                       break;
+               case MANGAHUB:
+                       support = new MangaHub();
+                       break;
+               case E621:
+                       support = new E621();
+                       break;
+               case YIFFSTAR:
+                       support = new YiffStar();
+                       break;
+               case E_HENTAI:
+                       support = new EHentai();
+                       break;
+               case MANGA_LEL:
+                       support = new MangaLel();
+                       break;
+               case CBZ:
+                       support = new Cbz();
+                       break;
+               case HTML:
+                       support = new Html();
+                       break;
+               }
+
+               if (support != null) {
+                       support.setType(type);
+                       support.source = support.getCanonicalUrl(url);
+               }
+
+               return support;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java b/src/be/nikiroo/fanfix/supported/BasicSupportHelper.java
new file mode 100644 (file)
index 0000000..7768052
--- /dev/null
@@ -0,0 +1,281 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to text formating for
+ * the classes that implement {@link BasicSupport}.
+ * 
+ * @author niki
+ */
+public class BasicSupportHelper {
+       /**
+        * Get the default cover related to this subject (see <tt>.info</tt> files).
+        * 
+        * @param subject
+        *            the subject
+        * 
+        * @return the cover if any, or NULL
+        */
+       public Image getDefaultCover(String subject) {
+               if (subject != null && !subject.isEmpty() && Instance.getInstance().getCoverDir() != null) {
+                       try {
+                               File fileCover = new File(Instance.getInstance().getCoverDir(), subject);
+                               return getImage(null, fileCover.toURI().toURL(), subject);
+                       } catch (MalformedURLException e) {
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @param emptyAllowed
+        *            TRUE to allow an empty extension on first place, which can be
+        *            used when you may already have an extension in your input but
+        *            are not sure about it
+        * 
+        * @return the extensions
+        */
+       public String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+
+               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL)
+        * @param source
+        *            the source of the story (for image lookup in the same path if
+        *            the source is a file, can be NULL)
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image if found, or NULL
+        * 
+        */
+       public Image getImage(BasicSupport support, URL source, String line) {
+               URL url = getImageUrl(support, source, line);
+               if (url != null) {
+                       if ("file".equals(url.getProtocol())) {
+                               if (new File(url.getPath()).isDirectory()) {
+                                       return null;
+                               }
+                       }
+                       InputStream in = null;
+                       try {
+                               in = Instance.getInstance().getCache().open(url, support, true);
+                               return new Image(in);
+                       } catch (IOException e) {
+                       } finally {
+                               if (in != null) {
+                                       try {
+                                               in.close();
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL)
+        * @param source
+        *            the source of the story (for image lookup in the same path if
+        *            the source is a file, can be NULL)
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       public URL getImageUrl(BasicSupport support, URL source, String line) {
+               URL url = null;
+
+               if (line != null) {
+                       // try for files
+                       if (source != null) {
+                               try {
+
+                                       String relPath = null;
+                                       String absPath = null;
+                                       try {
+                                               String path = new File(source.getFile()).getParent();
+                                               relPath = new File(new File(path), line.trim())
+                                                               .getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (one possibility to take
+                                               // into account: absolute path on Windows)
+                                       }
+                                       try {
+                                               absPath = new File(line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (at all)
+                                       }
+
+                                       for (String ext : getImageExt(true)) {
+                                               File absFile = new File(absPath + ext);
+                                               File relFile = new File(relPath + ext);
+                                               if (absPath != null && absFile.exists()
+                                                               && absFile.isFile()) {
+                                                       url = absFile.toURI().toURL();
+                                               } else if (relPath != null && relFile.exists()
+                                                               && relFile.isFile()) {
+                                                       url = relFile.toURI().toURL();
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       // Should not happen since we control the correct arguments
+                               }
+                       }
+
+                       if (url == null) {
+                               // try for URLs
+                               try {
+                                       for (String ext : getImageExt(true)) {
+                                               if (Instance.getInstance().getCache().check(new URL(line + ext), true)) {
+                                                       url = new URL(line + ext);
+                                                       break;
+                                               }
+                                       }
+
+                                       // try out of cache
+                                       if (url == null) {
+                                               for (String ext : getImageExt(true)) {
+                                                       try {
+                                                               url = new URL(line + ext);
+                                                               Instance.getInstance().getCache().refresh(url, support, true);
+                                                               break;
+                                                       } catch (IOException e) {
+                                                               // no image with this ext
+                                                               url = null;
+                                                       }
+                                               }
+                                       }
+                               } catch (MalformedURLException e) {
+                                       // Not an url
+                               }
+                       }
+
+                       // refresh the cached file
+                       if (url != null) {
+                               try {
+                                       Instance.getInstance().getCache().refresh(url, support, true);
+                               } catch (IOException e) {
+                                       // woops, broken image
+                                       url = null;
+                               }
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Fix the author name if it is prefixed with some "by" {@link String}.
+        * 
+        * @param author
+        *            the author with a possible prefix
+        * 
+        * @return the author without prefixes
+        */
+       public String fixAuthor(String author) {
+               if (author != null) {
+                       for (String suffix : new String[] { " ", ":" }) {
+                               for (String byString : Instance.getInstance().getConfig().getList(Config.CONF_BYS)) {
+                                       byString += suffix;
+                                       if (author.toUpperCase().startsWith(byString.toUpperCase())) {
+                                               author = author.substring(byString.length()).trim();
+                                       }
+                               }
+                       }
+
+                       // Special case (without suffix):
+                       if (author.startsWith("©")) {
+                               author = author.substring(1);
+                       }
+               }
+
+               return author;
+       }
+       
+       /**
+        * Try to convert the date to a known, fixed format.
+        * <p>
+        * If it fails to do so, it will return the date as-is.
+        * 
+        * @param date
+        *            the date to convert
+        * 
+        * @return the converted date, or the date as-is
+        */
+       public String formatDate(String date) {
+               long ms = 0;
+
+               if (date != null && !date.isEmpty()) {
+                       // Default Fanfix format:
+                       try {
+                               ms = StringUtils.toTime(date);
+                       } catch (ParseException e) {
+                       }
+
+                       // Second chance:
+                       if (ms <= 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat(
+                                               "yyyy-MM-dd'T'HH:mm:ssSSS");
+                               try {
+                                       ms = sdf.parse(date).getTime();
+                               } catch (ParseException e) {
+                               }
+                       }
+
+                       // Last chance:
+                       if (ms <= 0 && date.length() >= 10) {
+                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                               try {
+                                       ms = sdf.parse(date.substring(0, 10)).getTime();
+                               } catch (ParseException e) {
+                               }
+                       }
+
+                       // If we found something, use THIS format:
+                       if (ms > 0) {
+                               SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                               return sdf.format(new Date(ms));
+                       }
+               }
+
+               if (date == null) {
+                       date = "";
+               }
+
+               // :(
+               return date;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportImages.java b/src/be/nikiroo/fanfix/supported/BasicSupportImages.java
new file mode 100644 (file)
index 0000000..576cb17
--- /dev/null
@@ -0,0 +1,185 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.utils.Image;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to images for
+ * the classes that implement {@link BasicSupport}.
+ * 
+ * @author niki
+ */
+public class BasicSupportImages {
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param dir
+        *            the local directory to search, if any
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image if found, or NULL
+        */
+       public Image getImage(BasicSupport support, File dir, String line) {
+               URL url = getImageUrl(support, dir, line);
+               return getImage(support,url);
+       }
+       
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param url
+        *            the actual URL to check (file or remote, can be NULL)
+        * 
+        * @return the image if found, or NULL
+        */
+       public Image getImage(BasicSupport support, URL url) {
+               if (url != null) {
+                       if ("file".equals(url.getProtocol())) {
+                               if (new File(url.getPath()).isDirectory()) {
+                                       return null;
+                               }
+                       }
+                       InputStream in = null;
+                       try {
+                               in = Instance.getInstance().getCache().open(url, support, true);
+                               return new Image(in);
+                       } catch (IOException e) {
+                       } finally {
+                               if (in != null) {
+                                       try {
+                                               in.close();
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param support
+        *            the support to use to download the resource (can be NULL)
+        * @param dir
+        *            the local directory to search, if any
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       public URL getImageUrl(BasicSupport support, File dir, String line) {
+               URL url = null;
+
+               if (line != null) {
+                       // try for files
+                       if (dir != null && dir.exists() && !dir.isFile()) {
+                               try {
+
+                                       String relPath = null;
+                                       String absPath = null;
+                                       try {
+                                               relPath = new File(dir, line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (one possibility to take
+                                               // into account: absolute path on Windows)
+                                       }
+                                       try {
+                                               absPath = new File(line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (at all)
+                                       }
+
+                                       for (String ext : getImageExt(true)) {
+                                               File absFile = new File(absPath + ext);
+                                               File relFile = new File(relPath + ext);
+                                               if (absPath != null && absFile.exists()
+                                                               && absFile.isFile()) {
+                                                       url = absFile.toURI().toURL();
+                                               } else if (relPath != null && relFile.exists()
+                                                               && relFile.isFile()) {
+                                                       url = relFile.toURI().toURL();
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       // Should not happen since we control the correct arguments
+                               }
+                       }
+
+                       if (url == null) {
+                               // try for URLs
+                               try {
+                                       for (String ext : getImageExt(true)) {
+                                               if (Instance.getInstance().getCache()
+                                                               .check(new URL(line + ext), true)) {
+                                                       url = new URL(line + ext);
+                                                       break;
+                                               }
+                                       }
+
+                                       // try out of cache
+                                       if (url == null) {
+                                               for (String ext : getImageExt(true)) {
+                                                       try {
+                                                               url = new URL(line + ext);
+                                                               Instance.getInstance().getCache().refresh(url, support, true);
+                                                               break;
+                                                       } catch (IOException e) {
+                                                               // no image with this ext
+                                                               url = null;
+                                                       }
+                                               }
+                                       }
+                               } catch (MalformedURLException e) {
+                                       // Not an url
+                               }
+                       }
+
+                       // refresh the cached file
+                       if (url != null) {
+                               try {
+                                       Instance.getInstance().getCache().refresh(url, support, true);
+                               } catch (IOException e) {
+                                       // woops, broken image
+                                       url = null;
+                               }
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @param emptyAllowed
+        *            TRUE to allow an empty extension on first place, which can be
+        *            used when you may already have an extension in your input but
+        *            are not sure about it
+        * 
+        * @return the extensions
+        */
+       public String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+
+               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupportPara.java b/src/be/nikiroo/fanfix/supported/BasicSupportPara.java
new file mode 100644 (file)
index 0000000..1dbedc9
--- /dev/null
@@ -0,0 +1,579 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Helper class for {@link BasicSupport}, mostly dedicated to {@link Paragraph}
+ * and text formating for the {@link BasicSupport} class.
+ * 
+ * @author niki
+ */
+public class BasicSupportPara {
+       // quote chars
+       private static char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+       private static char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+       private static char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+       private static char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+       // used by this class:
+       BasicSupportHelper bsHelper;
+       BasicSupportImages bsImages;
+       
+       public BasicSupportPara(BasicSupportHelper bsHelper, BasicSupportImages bsImages) {
+               this.bsHelper = bsHelper;
+               this.bsImages = bsImages;
+       }
+       
+       /**
+        * Create a {@link Chapter} object from the given information, formatting
+        * the content as it should be.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport}
+        * @param source
+        *            the source of the story (for image lookup in the same path if
+        *            the source is a file, can be NULL)
+        * @param number
+        *            the chapter number
+        * @param name
+        *            the chapter name
+        * @param content
+        *            the chapter content
+        * @param pg
+        *            the optional progress reporter
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the {@link Chapter}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Chapter makeChapter(BasicSupport support, URL source,
+                       int number, String name, String content, boolean html, Progress pg)
+                       throws IOException {
+               // Chapter name: process it correctly, then remove the possible
+               // redundant "Chapter x: " in front of it, or "-" (as in
+               // "Chapter 5: - Fun!" after the ": " was automatically added)
+               String chapterName = processPara(name, false)
+                               .getContent().trim();
+               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
+                       String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
+                       if (chapterName.startsWith(chapterWord)) {
+                               chapterName = chapterName.substring(chapterWord.length())
+                                               .trim();
+                               break;
+                       }
+               }
+
+               if (chapterName.startsWith(Integer.toString(number))) {
+                       chapterName = chapterName.substring(
+                                       Integer.toString(number).length()).trim();
+               }
+
+               while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
+                       chapterName = chapterName.substring(1).trim();
+               }
+               //
+
+               Chapter chap = new Chapter(number, chapterName);
+
+               if (content != null) {
+                       List<Paragraph> paras = makeParagraphs(support, source, content,
+                                       html, pg);
+                       long words = 0;
+                       for (Paragraph para : paras) {
+                               words += para.getWords();
+                       }
+                       chap.setParagraphs(paras);
+                       chap.setWords(words);
+               }
+
+               return chap;
+       }
+
+       /**
+        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+        * and requotify them (i.e., separate them into QUOTE paragraphs and other
+        * paragraphs (quotes or not)).
+        * 
+        * @param para
+        *            the paragraph to requotify (not necessarily a quote)
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the correctly (or so we hope) quotified paragraphs
+        */
+       protected List<Paragraph> requotify(Paragraph para, boolean html) {
+               List<Paragraph> newParas = new ArrayList<Paragraph>();
+
+               if (para.getType() == ParagraphType.QUOTE
+                               && para.getContent().length() > 2) {
+                       String line = para.getContent();
+                       boolean singleQ = line.startsWith("" + openQuote);
+                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+                       // Do not try when more than one quote at a time
+                       // (some stories are not easily readable if we do)
+                       if (singleQ
+                                       && line.indexOf(closeQuote, 1) < line
+                                                       .lastIndexOf(closeQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       if (doubleQ
+                                       && line.indexOf(closeDoubleQuote, 1) < line
+                                                       .lastIndexOf(closeDoubleQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       //
+
+                       if (!singleQ && !doubleQ) {
+                               line = openDoubleQuote + line + closeDoubleQuote;
+                               newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
+                                               .getWords()));
+                       } else {
+                               char open = singleQ ? openQuote : openDoubleQuote;
+                               char close = singleQ ? closeQuote : closeDoubleQuote;
+
+                               int posDot = -1;
+                               boolean inQuote = false;
+                               int i = 0;
+                               for (char car : line.toCharArray()) {
+                                       if (car == open) {
+                                               inQuote = true;
+                                       } else if (car == close) {
+                                               inQuote = false;
+                                       } else if (car == '.' && !inQuote) {
+                                               posDot = i;
+                                               break;
+                                       }
+                                       i++;
+                               }
+
+                               if (posDot >= 0) {
+                                       String rest = line.substring(posDot + 1).trim();
+                                       line = line.substring(0, posDot + 1).trim();
+                                       long words = 1;
+                                       for (char car : line.toCharArray()) {
+                                               if (car == ' ') {
+                                                       words++;
+                                               }
+                                       }
+                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
+                                       if (!rest.isEmpty()) {
+                                               newParas.addAll(requotify(processPara(rest, html), html));
+                                       }
+                               } else {
+                                       newParas.add(para);
+                               }
+                       }
+               } else {
+                       newParas.add(para);
+               }
+
+               return newParas;
+       }
+
+       /**
+        * Process a {@link Paragraph} from a raw line of text.
+        * <p>
+        * Will also fix quotes and HTML encoding if needed.
+        * 
+        * @param line
+        *            the raw line
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the processed {@link Paragraph}, never NULL
+        */
+       protected Paragraph processPara(String line, boolean html) {
+               if (html) {
+                       line = StringUtils.unhtml(line).trim();
+               }
+               boolean space = true;
+               boolean brk = true;
+               boolean quote = false;
+               boolean tentativeCloseQuote = false;
+               char prev = '\0';
+               int dashCount = 0;
+               long words = 1;
+
+               StringBuilder builder = new StringBuilder();
+               for (char car : line.toCharArray()) {
+                       if (car != '-') {
+                               if (dashCount > 0) {
+                                       // dash, ndash and mdash: - – —
+                                       // currently: always use mdash
+                                       builder.append(dashCount == 1 ? '-' : '—');
+                               }
+                               dashCount = 0;
+                       }
+
+                       if (tentativeCloseQuote) {
+                               tentativeCloseQuote = false;
+                               if (Character.isLetterOrDigit(car)) {
+                                       builder.append("'");
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.append(closeDoubleQuote);
+                                               continue;
+                                       }
+
+                                       builder.append(closeQuote);
+                               }
+                       }
+
+                       switch (car) {
+                       case ' ': // note: unbreakable space
+                       case ' ':
+                       case '\t':
+                       case '\n': // just in case
+                       case '\r': // just in case
+                               if (builder.length() > 0
+                                               && builder.charAt(builder.length() - 1) != ' ') {
+                                       words++;
+                               }
+                               builder.append(' ');
+                               break;
+
+                       case '\'':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else if (prev == ' ' || prev == car) {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else {
+                                       // it is a quote ("I'm off") or a 'quote' ("This
+                                       // 'good' restaurant"...)
+                                       tentativeCloseQuote = true;
+                               }
+                               break;
+
+                       case '"':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else if (prev == ' ') {
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(closeDoubleQuote);
+                               }
+                               break;
+
+                       case '-':
+                               if (space) {
+                                       quote = true;
+                               } else {
+                                       dashCount++;
+                               }
+                               space = false;
+                               break;
+
+                       case '*':
+                       case '~':
+                       case '/':
+                       case '\\':
+                       case '<':
+                       case '>':
+                       case '=':
+                       case '+':
+                       case '_':
+                       case '–':
+                       case '—':
+                               space = false;
+                               builder.append(car);
+                               break;
+
+                       case '‘':
+                       case '`':
+                       case '‹':
+                       case '﹁':
+                       case '〈':
+                       case '「':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openQuote);
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '’':
+                       case '›':
+                       case '﹂':
+                       case '〉':
+                       case '」':
+                               space = false;
+                               brk = false;
+                               // handle double-single quotes as double quotes
+                               if (prev == car) {
+                                       builder.deleteCharAt(builder.length() - 1);
+                                       builder.append(closeDoubleQuote);
+                               } else {
+                                       builder.append(closeQuote);
+                               }
+                               break;
+
+                       case '«':
+                       case '“':
+                       case '﹃':
+                       case '《':
+                       case '『':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(openDoubleQuote);
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '»':
+                       case '”':
+                       case '﹄':
+                       case '》':
+                       case '』':
+                               space = false;
+                               brk = false;
+                               builder.append(closeDoubleQuote);
+                               break;
+
+                       default:
+                               space = false;
+                               brk = false;
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (tentativeCloseQuote) {
+                       tentativeCloseQuote = false;
+                       builder.append(closeQuote);
+               }
+
+               line = builder.toString().trim();
+
+               ParagraphType type = ParagraphType.NORMAL;
+               if (space) {
+                       type = ParagraphType.BLANK;
+               } else if (brk) {
+                       type = ParagraphType.BREAK;
+               } else if (quote) {
+                       type = ParagraphType.QUOTE;
+               }
+
+               return new Paragraph(type, line, words);
+       }
+
+       /**
+        * Convert the given content into {@link Paragraph}s.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL), used to
+        *            download optional image content in []
+        * @param source
+        *            the source URL of the story (for image lookup in the same path
+        *            if the source is a file, can be NULL)
+        * @param content
+        *            the textual content
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Paragraph}s (can be empty but never NULL)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected List<Paragraph> makeParagraphs(BasicSupport support,
+                       URL source, String content, boolean html, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               if (html) {
+                       // Special <HR> processing:
+                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
+                                       "<br/>* * *<br/>");
+               }
+
+               List<Paragraph> paras = new ArrayList<Paragraph>();
+
+               if (content != null && !content.trim().isEmpty()) {
+                       if (html) {
+                               String[] tab = content.split("(<p>|</p>|<br>|<br/>)");
+                               pg.setMinMax(0, tab.length);
+                               int i = 1;
+                               for (String line : tab) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(support, source, line.trim(), html));
+                                       pg.setProgress(i++);
+                               }
+                       } else {
+                               List<String> lines = new ArrayList<String>();
+                               BufferedReader buff = null;
+                               try {
+                                       buff = new BufferedReader(
+                                                       new InputStreamReader(new ByteArrayInputStream(
+                                                                       content.getBytes("UTF-8")), "UTF-8"));
+                                       for (String line = buff.readLine(); line != null; line = buff
+                                                       .readLine()) {
+                                               lines.add(line.trim());
+                                       }
+                               } finally {
+                                       if (buff != null) {
+                                               buff.close();
+                                       }
+                               }
+
+                               pg.setMinMax(0, lines.size());
+                               int i = 0;
+                               for (String line : lines) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(support, source, line, html));
+                                       pg.setProgress(i++);
+                               }
+                       }
+
+                       pg.done();
+                       pg.setName(null);
+
+                       // Check quotes for "bad" format
+                       List<Paragraph> newParas = new ArrayList<Paragraph>();
+                       for (Paragraph para : paras) {
+                               newParas.addAll(requotify(para, html));
+                       }
+                       paras = newParas;
+
+                       // Remove double blanks/brks
+                       fixBlanksBreaks(paras);
+               }
+
+               return paras;
+       }
+
+       /**
+        * Convert the given line into a single {@link Paragraph}.
+        * 
+        * @param support
+        *            the linked {@link BasicSupport} (can be NULL), used to
+        *            download optional image content in []
+        * @param source
+        *            the source URL of the story (for image lookup in the same path
+        *            if the source is a file, can be NULL)
+        * @param line
+        *            the textual content of the paragraph
+        * @param html
+        *            TRUE if the input content is in HTML mode
+        * 
+        * @return the {@link Paragraph}, never NULL
+        */
+       protected Paragraph makeParagraph(BasicSupport support, URL source,
+                       String line, boolean html) {
+               Image image = null;
+               if (line.startsWith("[") && line.endsWith("]")) {
+                       image = bsHelper.getImage(support, source, line
+                                       .substring(1, line.length() - 1).trim());
+               }
+
+               if (image != null) {
+                       return new Paragraph(image);
+               }
+
+               return processPara(line, html);
+       }
+
+       /**
+        * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
+        * those {@link Paragraph}s.
+        * <p>
+        * The resulting list will not contain a starting or trailing blank/break
+        * nor 2 blanks or breaks following each other.
+        * 
+        * @param paras
+        *            the list of {@link Paragraph}s to fix
+        */
+       protected void fixBlanksBreaks(List<Paragraph> paras) {
+               boolean space = false;
+               boolean brk = true;
+               for (int i = 0; i < paras.size(); i++) {
+                       Paragraph para = paras.get(i);
+                       boolean thisSpace = para.getType() == ParagraphType.BLANK;
+                       boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+                       if (i > 0 && space && thisBrk) {
+                               paras.remove(i - 1);
+                               i--;
+                       } else if ((space || brk) && (thisSpace || thisBrk)) {
+                               paras.remove(i);
+                               i--;
+                       }
+
+                       space = thisSpace;
+                       brk = thisBrk;
+               }
+
+               // Remove blank/brk at start
+               if (paras.size() > 0
+                               && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
+                                               0).getType() == ParagraphType.BREAK)) {
+                       paras.remove(0);
+               }
+
+               // Remove blank/brk at end
+               int last = paras.size() - 1;
+               if (paras.size() > 0
+                               && (paras.get(last).getType() == ParagraphType.BLANK || paras
+                                               .get(last).getType() == ParagraphType.BREAK)) {
+                       paras.remove(last);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java b/src/be/nikiroo/fanfix/supported/BasicSupport_Deprecated.java
new file mode 100644 (file)
index 0000000..a50ee3c
--- /dev/null
@@ -0,0 +1,1327 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * DEPRECATED: use the new Jsoup 'Node' system.
+ * <p>
+ * This class is the base class used by the other support classes. It can be
+ * used outside of this package, and have static method that you can use to get
+ * access to the correct support class.
+ * <p>
+ * It will be used with 'resources' (usually web pages or files).
+ * 
+ * @author niki
+ */
+@Deprecated
+public abstract class BasicSupport_Deprecated extends BasicSupport {
+       private InputStream in;
+
+       // quote chars
+       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+       // New methods not used in Deprecated mode
+       @Override
+       protected String getDesc() throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
+               throw new RuntimeException("should not be used by legacy code");
+       }
+
+       @Override
+       public Story process(Progress pg) throws IOException {
+               return process(getSource(), pg);
+       }
+
+       //
+
+       /**
+        * Return the {@link MetaData} of this story.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the associated {@link MetaData}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract MetaData getMeta(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the story description.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @return the description
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getDesc(URL source, InputStream in)
+                       throws IOException;
+
+       /**
+        * Return the list of chapters (name and resource).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the chapters
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract List<Entry<String, URL>> getChapters(URL source,
+                       InputStream in, Progress pg) throws IOException;
+
+       /**
+        * Return the content of the chapter (possibly HTML encoded, if
+        * {@link BasicSupport_Deprecated#isHtml()} is TRUE).
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * @param number
+        *            the chapter number
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the content
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected abstract String getChapterContent(URL source, InputStream in,
+                       int number, Progress pg) throws IOException;
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata, except for the description.
+        * 
+        * @param url
+        *            the story resource
+        * 
+        * @return the {@link Story}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       public Story processMeta(URL url) throws IOException {
+               return processMeta(url, true, false, null);
+       }
+
+       /**
+        * Process the given story resource into a partially filled {@link Story}
+        * object containing the name and metadata.
+        * 
+        * @param url
+        *            the story resource
+        * @param close
+        *            close "this" and "in" when done
+        * @param getDesc
+        *            retrieve the description of the story, or not
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story processMeta(URL url, boolean close, boolean getDesc,
+                       Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               login();
+               pg.setProgress(10);
+
+               url = getCanonicalUrl(url);
+
+               setCurrentReferer(url);
+
+               in = openInput(url); // NULL allowed here
+               try {
+                       preprocess(url, getInput());
+                       pg.setProgress(30);
+
+                       Story story = new Story();
+                       MetaData meta = getMeta(url, getInput());
+                       if (meta.getCreationDate() == null
+                                       || meta.getCreationDate().trim().isEmpty()) {
+                               meta.setCreationDate(bsHelper.formatDate(
+                                               StringUtils.fromTime(new Date().getTime())));
+                       }
+                       story.setMeta(meta);
+                       pg.put("meta", meta);
+
+                       pg.setProgress(50);
+
+                       if (meta.getCover() == null) {
+                               meta.setCover(getDefaultCover(meta.getSubject()));
+                       }
+
+                       pg.setProgress(60);
+
+                       if (getDesc) {
+                               String descChapterName = Instance.getInstance().getTrans().getString(StringId.DESCRIPTION);
+                               story.getMeta().setResume(makeChapter(url, 0, descChapterName, getDesc(url, getInput()), null));
+                       }
+
+                       pg.setProgress(100);
+                       return story;
+               } finally {
+                       if (close) {
+                               close();
+
+                               if (in != null) {
+                                       in.close();
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Process the given story resource into a fully filled {@link Story}
+        * object.
+        * 
+        * @param url
+        *            the story resource
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Story}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Story process(URL url, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+
+               url = getCanonicalUrl(url);
+               pg.setProgress(1);
+               try {
+                       Progress pgMeta = new Progress();
+                       pg.addProgress(pgMeta, 10);
+                       Story story = processMeta(url, false, true, pgMeta);
+                       pg.put("meta", story.getMeta());
+                       if (!pgMeta.isDone()) {
+                               pgMeta.setProgress(pgMeta.getMax()); // 10%
+                       }
+
+                       setCurrentReferer(url);
+
+                       Progress pgGetChapters = new Progress();
+                       pg.addProgress(pgGetChapters, 10);
+                       story.setChapters(new ArrayList<Chapter>());
+                       List<Entry<String, URL>> chapters = getChapters(url, getInput(),
+                                       pgGetChapters);
+                       if (!pgGetChapters.isDone()) {
+                               pgGetChapters.setProgress(pgGetChapters.getMax()); // 20%
+                       }
+
+                       if (chapters != null) {
+                               Progress pgChaps = new Progress("Extracting chapters", 0,
+                                               chapters.size() * 300);
+                               pg.addProgress(pgChaps, 80);
+
+                               long words = 0;
+                               int i = 1;
+                               for (Entry<String, URL> chap : chapters) {
+                                       pgChaps.setName("Extracting chapter " + i);
+                                       InputStream chapIn = null;
+                                       if (chap.getValue() != null) {
+                                               setCurrentReferer(chap.getValue());
+                                               chapIn = Instance.getInstance().getCache().open(chap.getValue(), this, false);
+                                       }
+                                       pgChaps.setProgress(i * 100);
+                                       try {
+                                               Progress pgGetChapterContent = new Progress();
+                                               Progress pgMakeChapter = new Progress();
+                                               pgChaps.addProgress(pgGetChapterContent, 100);
+                                               pgChaps.addProgress(pgMakeChapter, 100);
+
+                                               String content = getChapterContent(url, chapIn, i,
+                                                               pgGetChapterContent);
+                                               if (!pgGetChapterContent.isDone()) {
+                                                       pgGetChapterContent.setProgress(pgGetChapterContent
+                                                                       .getMax());
+                                               }
+
+                                               Chapter cc = makeChapter(url, i, chap.getKey(),
+                                                               content, pgMakeChapter);
+                                               if (!pgMakeChapter.isDone()) {
+                                                       pgMakeChapter.setProgress(pgMakeChapter.getMax());
+                                               }
+
+                                               words += cc.getWords();
+                                               story.getChapters().add(cc);
+                                       } finally {
+                                               if (chapIn != null) {
+                                                       chapIn.close();
+                                               }
+                                       }
+
+                                       i++;
+                               }
+                               
+                               story.getMeta().setWords(words);
+
+                               pgChaps.setName("Extracting chapters");
+                       } else {
+                               pg.setProgress(80);
+                       }
+
+                       // Check for "no chapters" stories
+                       if (story.getChapters().isEmpty()
+                                       && story.getMeta().getResume() != null
+                                       && !story.getMeta().getResume().getParagraphs().isEmpty()) {
+                               Chapter resume = story.getMeta().getResume();
+                               resume.setName("");
+                               resume.setNumber(1);
+                               story.getChapters().add(resume);
+                               story.getMeta().setWords(resume.getWords());
+
+                               String descChapterName = Instance.getInstance().getTrans()
+                                               .getString(StringId.DESCRIPTION);
+                               resume = new Chapter(0, descChapterName);
+                               story.getMeta().setResume(resume);
+                       }
+
+                       return story;
+               } finally {
+                       close();
+
+                       if (in != null) {
+                               in.close();
+                       }
+               }
+       }
+
+       /**
+        * Prepare the support if needed before processing.
+        * 
+        * @param source
+        *            the source of the story
+        * @param in
+        *            the input (the main resource)
+        * 
+        * @throws IOException
+        *             on I/O error
+        */
+       @SuppressWarnings("unused")
+       protected void preprocess(URL source, InputStream in) throws IOException {
+       }
+
+       /**
+        * Create a {@link Chapter} object from the given information, formatting
+        * the content as it should be.
+        * 
+        * @param source
+        *            the source of the story
+        * @param number
+        *            the chapter number
+        * @param name
+        *            the chapter name
+        * @param content
+        *            the chapter content
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Chapter}, never NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected Chapter makeChapter(URL source, int number, String name,
+                       String content, Progress pg) throws IOException {
+               // Chapter name: process it correctly, then remove the possible
+               // redundant "Chapter x: " in front of it, or "-" (as in
+               // "Chapter 5: - Fun!" after the ": " was automatically added)
+               String chapterName = processPara(name).getContent().trim();
+               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
+                       String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
+                       if (chapterName.startsWith(chapterWord)) {
+                               chapterName = chapterName.substring(chapterWord.length())
+                                               .trim();
+                               break;
+                       }
+               }
+
+               if (chapterName.startsWith(Integer.toString(number))) {
+                       chapterName = chapterName.substring(
+                                       Integer.toString(number).length()).trim();
+               }
+
+               while (chapterName.startsWith(":") || chapterName.startsWith("-")) {
+                       chapterName = chapterName.substring(1).trim();
+               }
+               //
+
+               Chapter chap = new Chapter(number, chapterName);
+
+               if (content != null) {
+                       List<Paragraph> paras = makeParagraphs(source, content, pg);
+                       long words = 0;
+                       for (Paragraph para : paras) {
+                               words += para.getWords();
+                       }
+                       chap.setParagraphs(paras);
+                       chap.setWords(words);
+               }
+
+               return chap;
+
+       }
+
+       /**
+        * Convert the given content into {@link Paragraph}s.
+        * 
+        * @param source
+        *            the source URL of the story
+        * @param content
+        *            the textual content
+        * @param pg
+        *            the optional progress reporter
+        * 
+        * @return the {@link Paragraph}s (can be empty, but never NULL)
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected List<Paragraph> makeParagraphs(URL source, String content,
+                       Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               if (isHtml()) {
+                       // Special <HR> processing:
+                       content = content.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
+                                       "<br/>* * *<br/>");
+               }
+
+               List<Paragraph> paras = new ArrayList<Paragraph>();
+               if (content != null && !content.trim().isEmpty()) {
+                       if (isHtml()) {
+                               String[] tab = content.split("(<p>|</p>|<br>|<br/>)");
+                               pg.setMinMax(0, tab.length);
+                               int i = 1;
+                               for (String line : tab) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(source, line.trim()));
+                                       pg.setProgress(i++);
+                               }
+                               pg.setName(null);
+                       } else {
+                               List<String> lines = new ArrayList<String>();
+                               BufferedReader buff = null;
+                               try {
+                                       buff = new BufferedReader(
+                                                       new InputStreamReader(new ByteArrayInputStream(
+                                                                       content.getBytes("UTF-8")), "UTF-8"));
+                                       for (String line = buff.readLine(); line != null; line = buff
+                                                       .readLine()) {
+                                               lines.add(line.trim());
+                                       }
+                               } finally {
+                                       if (buff != null) {
+                                               buff.close();
+                                       }
+                               }
+
+                               pg.setMinMax(0, lines.size());
+                               int i = 0;
+                               for (String line : lines) {
+                                       if (line.startsWith("[") && line.endsWith("]")) {
+                                               pg.setName("Extracting image " + i);
+                                       }
+                                       paras.add(makeParagraph(source, line));
+                                       pg.setProgress(i++);
+                               }
+                               pg.setName(null);
+                       }
+
+                       // Check quotes for "bad" format
+                       List<Paragraph> newParas = new ArrayList<Paragraph>();
+                       for (Paragraph para : paras) {
+                               newParas.addAll(requotify(para));
+                       }
+                       paras = newParas;
+
+                       // Remove double blanks/brks
+                       fixBlanksBreaks(paras);
+               }
+
+               return paras;
+       }
+
+       /**
+        * Convert the given line into a single {@link Paragraph}.
+        * 
+        * @param source
+        *            the source URL of the story
+        * @param line
+        *            the textual content of the paragraph
+        * 
+        * @return the {@link Paragraph}, never NULL
+        */
+       private Paragraph makeParagraph(URL source, String line) {
+               Image image = null;
+               if (line.startsWith("[") && line.endsWith("]")) {
+                       image = getImage(this, source, line.substring(1, line.length() - 1)
+                                       .trim());
+               }
+
+               if (image != null) {
+                       return new Paragraph(image);
+               }
+
+               return processPara(line);
+       }
+
+       /**
+        * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
+        * those {@link Paragraph}s.
+        * <p>
+        * The resulting list will not contain a starting or trailing blank/break
+        * nor 2 blanks or breaks following each other.
+        * 
+        * @param paras
+        *            the list of {@link Paragraph}s to fix
+        */
+       protected void fixBlanksBreaks(List<Paragraph> paras) {
+               boolean space = false;
+               boolean brk = true;
+               for (int i = 0; i < paras.size(); i++) {
+                       Paragraph para = paras.get(i);
+                       boolean thisSpace = para.getType() == ParagraphType.BLANK;
+                       boolean thisBrk = para.getType() == ParagraphType.BREAK;
+
+                       if (i > 0 && space && thisBrk) {
+                               paras.remove(i - 1);
+                               i--;
+                       } else if ((space || brk) && (thisSpace || thisBrk)) {
+                               paras.remove(i);
+                               i--;
+                       }
+
+                       space = thisSpace;
+                       brk = thisBrk;
+               }
+
+               // Remove blank/brk at start
+               if (paras.size() > 0
+                               && (paras.get(0).getType() == ParagraphType.BLANK || paras.get(
+                                               0).getType() == ParagraphType.BREAK)) {
+                       paras.remove(0);
+               }
+
+               // Remove blank/brk at end
+               int last = paras.size() - 1;
+               if (paras.size() > 0
+                               && (paras.get(last).getType() == ParagraphType.BLANK || paras
+                                               .get(last).getType() == ParagraphType.BREAK)) {
+                       paras.remove(last);
+               }
+       }
+
+       /**
+        * Get the default cover related to this subject (see <tt>.info</tt> files).
+        * 
+        * @param subject
+        *            the subject
+        * 
+        * @return the cover if any, or NULL
+        */
+       static Image getDefaultCover(String subject) {
+               if (subject != null && !subject.isEmpty() && Instance.getInstance().getCoverDir() != null) {
+                       try {
+                               File fileCover = new File(Instance.getInstance().getCoverDir(), subject);
+                               return getImage(null, fileCover.toURI().toURL(), subject);
+                       } catch (MalformedURLException e) {
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the list of supported image extensions.
+        * 
+        * @param emptyAllowed
+        *            TRUE to allow an empty extension on first place, which can be
+        *            used when you may already have an extension in your input but
+        *            are not sure about it
+        * 
+        * @return the extensions
+        */
+       static String[] getImageExt(boolean emptyAllowed) {
+               if (emptyAllowed) {
+                       return new String[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+               }
+
+               return new String[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param source
+        *            the story source
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image if found, or NULL
+        * 
+        */
+       static Image getImage(BasicSupport_Deprecated support, URL source,
+                       String line) {
+               URL url = getImageUrl(support, source, line);
+               if (url != null) {
+                       if ("file".equals(url.getProtocol())) {
+                               if (new File(url.getPath()).isDirectory()) {
+                                       return null;
+                               }
+                       }
+                       InputStream in = null;
+                       try {
+                               in = Instance.getInstance().getCache().open(url, getSupport(url), true);
+                               return new Image(in);
+                       } catch (IOException e) {
+                       } finally {
+                               if (in != null) {
+                                       try {
+                                               in.close();
+                                       } catch (IOException e) {
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Check if the given resource can be a local image or a remote image, then
+        * refresh the cache with it if it is.
+        * 
+        * @param source
+        *            the story source
+        * @param line
+        *            the resource to check
+        * 
+        * @return the image URL if found, or NULL
+        * 
+        */
+       static URL getImageUrl(BasicSupport_Deprecated support, URL source,
+                       String line) {
+               URL url = null;
+
+               if (line != null) {
+                       // try for files
+                       if (source != null) {
+                               try {
+                                       String relPath = null;
+                                       String absPath = null;
+                                       try {
+                                               String path = new File(source.getFile()).getParent();
+                                               relPath = new File(new File(path), line.trim())
+                                                               .getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (one possibility to take
+                                               // into account: absolute path on Windows)
+                                       }
+                                       try {
+                                               absPath = new File(line.trim()).getAbsolutePath();
+                                       } catch (Exception e) {
+                                               // Cannot be converted to path (at all)
+                                       }
+
+                                       for (String ext : getImageExt(true)) {
+                                               File absFile = new File(absPath + ext);
+                                               File relFile = new File(relPath + ext);
+                                               if (absPath != null && absFile.exists()
+                                                               && absFile.isFile()) {
+                                                       url = absFile.toURI().toURL();
+                                               } else if (relPath != null && relFile.exists()
+                                                               && relFile.isFile()) {
+                                                       url = relFile.toURI().toURL();
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       // Should not happen since we control the correct arguments
+                               }
+                       }
+
+                       if (url == null) {
+                               // try for URLs
+                               try {
+                                       for (String ext : getImageExt(true)) {
+                                               if (Instance.getInstance().getCache().check(new URL(line + ext), true)) {
+                                                       url = new URL(line + ext);
+                                                       break;
+                                               }
+                                       }
+
+                                       // try out of cache
+                                       if (url == null) {
+                                               for (String ext : getImageExt(true)) {
+                                                       try {
+                                                               url = new URL(line + ext);
+                                                               Instance.getInstance().getCache().refresh(url, support, true);
+                                                               break;
+                                                       } catch (IOException e) {
+                                                               // no image with this ext
+                                                               url = null;
+                                                       }
+                                               }
+                                       }
+                               } catch (MalformedURLException e) {
+                                       // Not an url
+                               }
+                       }
+
+                       // refresh the cached file
+                       if (url != null) {
+                               try {
+                                       Instance.getInstance().getCache().refresh(url, support, true);
+                               } catch (IOException e) {
+                                       // woops, broken image
+                                       url = null;
+                               }
+                       }
+               }
+
+               return url;
+       }
+
+       /**
+        * Open the input file that will be used through the support.
+        * <p>
+        * Can return NULL, in which case you are supposed to work without an
+        * {@link InputStream}.
+        * 
+        * @param source
+        *            the source {@link URL}
+        * 
+        * @return the {@link InputStream}
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       protected InputStream openInput(URL source) throws IOException {
+               return Instance.getInstance().getCache().open(source, this, false);
+       }
+
+       /**
+        * Reset then return {@link BasicSupport_Deprecated#in}.
+        * 
+        * @return {@link BasicSupport_Deprecated#in}
+        */
+       protected InputStream getInput() {
+               return reset(in);
+       }
+
+       /**
+        * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
+        * and requotify them (i.e., separate them into QUOTE paragraphs and other
+        * paragraphs (quotes or not)).
+        * 
+        * @param para
+        *            the paragraph to requotify (not necessarily a quote)
+        * 
+        * @return the correctly (or so we hope) quotified paragraphs
+        */
+       protected List<Paragraph> requotify(Paragraph para) {
+               List<Paragraph> newParas = new ArrayList<Paragraph>();
+
+               if (para.getType() == ParagraphType.QUOTE
+                               && para.getContent().length() > 2) {
+                       String line = para.getContent();
+                       boolean singleQ = line.startsWith("" + openQuote);
+                       boolean doubleQ = line.startsWith("" + openDoubleQuote);
+
+                       // Do not try when more than one quote at a time
+                       // (some stories are not easily readable if we do)
+                       if (singleQ
+                                       && line.indexOf(closeQuote, 1) < line
+                                                       .lastIndexOf(closeQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       if (doubleQ
+                                       && line.indexOf(closeDoubleQuote, 1) < line
+                                                       .lastIndexOf(closeDoubleQuote)) {
+                               newParas.add(para);
+                               return newParas;
+                       }
+                       //
+
+                       if (!singleQ && !doubleQ) {
+                               line = openDoubleQuote + line + closeDoubleQuote;
+                               newParas.add(new Paragraph(ParagraphType.QUOTE, line, para
+                                               .getWords()));
+                       } else {
+                               char open = singleQ ? openQuote : openDoubleQuote;
+                               char close = singleQ ? closeQuote : closeDoubleQuote;
+
+                               int posDot = -1;
+                               boolean inQuote = false;
+                               int i = 0;
+                               for (char car : line.toCharArray()) {
+                                       if (car == open) {
+                                               inQuote = true;
+                                       } else if (car == close) {
+                                               inQuote = false;
+                                       } else if (car == '.' && !inQuote) {
+                                               posDot = i;
+                                               break;
+                                       }
+                                       i++;
+                               }
+
+                               if (posDot >= 0) {
+                                       String rest = line.substring(posDot + 1).trim();
+                                       line = line.substring(0, posDot + 1).trim();
+                                       long words = 1;
+                                       for (char car : line.toCharArray()) {
+                                               if (car == ' ') {
+                                                       words++;
+                                               }
+                                       }
+                                       newParas.add(new Paragraph(ParagraphType.QUOTE, line, words));
+                                       if (!rest.isEmpty()) {
+                                               newParas.addAll(requotify(processPara(rest)));
+                                       }
+                               } else {
+                                       newParas.add(para);
+                               }
+                       }
+               } else {
+                       newParas.add(para);
+               }
+
+               return newParas;
+       }
+
+       /**
+        * Process a {@link Paragraph} from a raw line of text.
+        * <p>
+        * Will also fix quotes and HTML encoding if needed.
+        * 
+        * @param line
+        *            the raw line
+        * 
+        * @return the processed {@link Paragraph}, never NULL
+        */
+       protected Paragraph processPara(String line) {
+               line = ifUnhtml(line).trim();
+
+               boolean space = true;
+               boolean brk = true;
+               boolean quote = false;
+               boolean tentativeCloseQuote = false;
+               char prev = '\0';
+               int dashCount = 0;
+               long words = 1;
+
+               StringBuilder builder = new StringBuilder();
+               for (char car : line.toCharArray()) {
+                       if (car != '-') {
+                               if (dashCount > 0) {
+                                       // dash, ndash and mdash: - – —
+                                       // currently: always use mdash
+                                       builder.append(dashCount == 1 ? '-' : '—');
+                               }
+                               dashCount = 0;
+                       }
+
+                       if (tentativeCloseQuote) {
+                               tentativeCloseQuote = false;
+                               if (Character.isLetterOrDigit(car)) {
+                                       builder.append("'");
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.append(closeDoubleQuote);
+                                               continue;
+                                       }
+
+                                       builder.append(closeQuote);
+                               }
+                       }
+
+                       switch (car) {
+                       case ' ': // note: unbreakable space
+                       case ' ':
+                       case '\t':
+                       case '\n': // just in case
+                       case '\r': // just in case
+                               if (builder.length() > 0
+                                               && builder.charAt(builder.length() - 1) != ' ') {
+                                       words++;
+                               }
+                               builder.append(' ');
+                               break;
+
+                       case '\'':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else if (prev == ' ' || prev == car) {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               } else {
+                                       // it is a quote ("I'm off") or a 'quote' ("This
+                                       // 'good' restaurant"...)
+                                       tentativeCloseQuote = true;
+                               }
+                               break;
+
+                       case '"':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else if (prev == ' ') {
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(closeDoubleQuote);
+                               }
+                               break;
+
+                       case '-':
+                               if (space) {
+                                       quote = true;
+                               } else {
+                                       dashCount++;
+                               }
+                               space = false;
+                               break;
+
+                       case '*':
+                       case '~':
+                       case '/':
+                       case '\\':
+                       case '<':
+                       case '>':
+                       case '=':
+                       case '+':
+                       case '_':
+                       case '–':
+                       case '—':
+                               space = false;
+                               builder.append(car);
+                               break;
+
+                       case '‘':
+                       case '`':
+                       case '‹':
+                       case '﹁':
+                       case '〈':
+                       case '「':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openQuote);
+                               } else {
+                                       // handle double-single quotes as double quotes
+                                       if (prev == car) {
+                                               builder.deleteCharAt(builder.length() - 1);
+                                               builder.append(openDoubleQuote);
+                                       } else {
+                                               builder.append(openQuote);
+                                       }
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '’':
+                       case '›':
+                       case '﹂':
+                       case '〉':
+                       case '」':
+                               space = false;
+                               brk = false;
+                               // handle double-single quotes as double quotes
+                               if (prev == car) {
+                                       builder.deleteCharAt(builder.length() - 1);
+                                       builder.append(closeDoubleQuote);
+                               } else {
+                                       builder.append(closeQuote);
+                               }
+                               break;
+
+                       case '«':
+                       case '“':
+                       case '﹃':
+                       case '《':
+                       case '『':
+                               if (space || (brk && quote)) {
+                                       quote = true;
+                                       builder.append(openDoubleQuote);
+                               } else {
+                                       builder.append(openDoubleQuote);
+                               }
+                               space = false;
+                               brk = false;
+                               break;
+
+                       case '»':
+                       case '”':
+                       case '﹄':
+                       case '》':
+                       case '』':
+                               space = false;
+                               brk = false;
+                               builder.append(closeDoubleQuote);
+                               break;
+
+                       default:
+                               space = false;
+                               brk = false;
+                               builder.append(car);
+                               break;
+                       }
+
+                       prev = car;
+               }
+
+               if (tentativeCloseQuote) {
+                       tentativeCloseQuote = false;
+                       builder.append(closeQuote);
+               }
+
+               line = builder.toString().trim();
+
+               ParagraphType type = ParagraphType.NORMAL;
+               if (space) {
+                       type = ParagraphType.BLANK;
+               } else if (brk) {
+                       type = ParagraphType.BREAK;
+               } else if (quote) {
+                       type = ParagraphType.QUOTE;
+               }
+
+               return new Paragraph(type, line, words);
+       }
+
+       /**
+        * Remove the HTML from the input <b>if</b>
+        * {@link BasicSupport_Deprecated#isHtml()} is true.
+        * 
+        * @param input
+        *            the input
+        * 
+        * @return the no html version if needed
+        */
+       private String ifUnhtml(String input) {
+               if (isHtml() && input != null) {
+                       return StringUtils.unhtml(input);
+               }
+
+               return input;
+       }
+
+       /**
+        * Reset the given {@link InputStream} and return it.
+        * 
+        * @param in
+        *            the {@link InputStream} to reset
+        * 
+        * @return the same {@link InputStream} after reset
+        */
+       static protected InputStream reset(InputStream in) {
+               try {
+                       if (in != null) {
+                               in.reset();
+                       }
+               } catch (IOException e) {
+               }
+
+               return in;
+       }
+
+       /**
+        * Return the first line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * 
+        * @return the line, or NULL if not found
+        */
+       static protected String getLine(InputStream in, String needle,
+                       int relativeLine) {
+               return getLine(in, needle, relativeLine, true);
+       }
+
+       /**
+        * Return a line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * @param first
+        *            takes the first result (as opposed to the last one, which will
+        *            also always spend the input)
+        * 
+        * @return the line, or NULL if not found
+        */
+       static protected String getLine(InputStream in, String needle,
+                       int relativeLine, boolean first) {
+               String rep = null;
+
+               reset(in);
+
+               List<String> lines = new ArrayList<String>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               int index = -1;
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       lines.add(scan.next());
+
+                       if (index == -1) {
+                               if (needle.startsWith("^")) {
+                                       if (lines.get(lines.size() - 1).startsWith(
+                                                       needle.substring(1))) {
+                                               index = lines.size() - 1;
+                                       }
+
+                               } else {
+                                       if (lines.get(lines.size() - 1).contains(needle)) {
+                                               index = lines.size() - 1;
+                                       }
+                               }
+                       }
+
+                       if (index >= 0 && index + relativeLine < lines.size()) {
+                               rep = lines.get(index + relativeLine);
+                               if (first) {
+                                       break;
+                               }
+                       }
+               }
+
+               return rep;
+       }
+
+       /**
+        * Return the text between the key and the endKey (and optional subKey can
+        * be passed, in this case we will look for the key first, then take the
+        * text between the subKey and the endKey).
+        * <p>
+        * Will only match the first line with the given key if more than one are
+        * possible. Which also means that if the subKey or endKey is not found on
+        * that line, NULL will be returned.
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match (also supports "^" at start to say
+        *            "only if it starts with" the key)
+        * @param subKey
+        *            the sub key or NULL if none
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @return the text or NULL if not found
+        */
+       static protected String getKeyLine(InputStream in, String key,
+                       String subKey, String endKey) {
+               return getKeyText(getLine(in, key, 0), key, subKey, endKey);
+       }
+
+       /**
+        * Return the text between the key and the endKey (and optional subKey can
+        * be passed, in this case we will look for the key first, then take the
+        * text between the subKey and the endKey).
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match (also supports "^" at start to say
+        *            "only if it starts with" the key)
+        * @param subKey
+        *            the sub key or NULL if none
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @return the text or NULL if not found
+        */
+       static protected String getKeyText(String in, String key, String subKey,
+                       String endKey) {
+               String result = null;
+
+               String line = in;
+               if (line != null && line.contains(key)) {
+                       line = line.substring(line.indexOf(key) + key.length());
+                       if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
+                               if (subKey != null) {
+                                       line = line.substring(line.indexOf(subKey)
+                                                       + subKey.length());
+                               }
+                               if (endKey == null || line.contains(endKey)) {
+                                       if (endKey != null) {
+                                               line = line.substring(0, line.indexOf(endKey));
+                                               result = line;
+                                       }
+                               }
+                       }
+               }
+
+               return result;
+       }
+
+       /**
+        * Return the text between the key and the endKey (optional subKeys can be
+        * passed, in this case we will look for the subKeys first, then take the
+        * text between the key and the endKey).
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @param afters
+        *            the sub-keys to find before checking for key/endKey
+        * 
+        * @return the text or NULL if not found
+        */
+       static protected String getKeyTextAfter(String in, String key,
+                       String endKey, String... afters) {
+
+               if (in != null && !in.isEmpty()) {
+                       int pos = indexOfAfter(in, 0, afters);
+                       if (pos < 0) {
+                               return null;
+                       }
+
+                       in = in.substring(pos);
+               }
+
+               return getKeyText(in, key, null, endKey);
+       }
+
+       /**
+        * Return the first index after all the given "afters" have been found in
+        * the {@link String}, or -1 if it was not possible.
+        * 
+        * @param in
+        *            the input
+        * @param startAt
+        *            start at this position in the string
+        * @param afters
+        *            the sub-keys to find before checking for key/endKey
+        * 
+        * @return the text or NULL if not found
+        */
+       static protected int indexOfAfter(String in, int startAt, String... afters) {
+               int pos = -1;
+               if (in != null && !in.isEmpty()) {
+                       pos = startAt;
+                       if (afters != null) {
+                               for (int i = 0; pos >= 0 && i < afters.length; i++) {
+                                       String subKey = afters[i];
+                                       if (!subKey.isEmpty()) {
+                                               pos = in.indexOf(subKey, pos);
+                                               if (pos >= 0) {
+                                                       pos += subKey.length();
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return pos;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Cbz.java b/src/be/nikiroo/fanfix/supported/Cbz.java
new file mode 100644 (file)
index 0000000..7fe496d
--- /dev/null
@@ -0,0 +1,222 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for CBZ files (works better with CBZ created with this program,
+ * as they have some metadata available).
+ * 
+ * @author niki
+ */
+class Cbz extends Epub {
+       @Override
+       protected boolean supports(URL url) {
+               return url.toString().toLowerCase().endsWith(".cbz");
+       }
+
+       @Override
+       protected String getDataPrefix() {
+               return "";
+       }
+
+       @Override
+       protected boolean requireInfo() {
+               return false;
+       }
+
+       @Override
+       protected boolean isImagesDocumentByDefault() {
+               return true;
+       }
+
+       @Override
+       protected boolean getCover() {
+               return false;
+       }
+
+       @Override
+       public Story doProcess(Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               } else {
+                       pg.setMinMax(0, 100);
+               }
+               
+               pg.setName("Initialising");
+
+               Progress pgMeta = new Progress();
+               pg.addProgress(pgMeta, 10);
+               Story story = processMeta(true, pgMeta);
+               MetaData meta = story.getMeta();
+
+               pgMeta.done(); // 10%
+               
+               File tmpDir = Instance.getInstance().getTempFiles().createTempDir("info-text");
+               String basename = null;
+
+               Map<String, Image> images = new HashMap<String, Image>();
+               InputStream cbzIn = null;
+               ZipInputStream zipIn = null;
+               try {
+                       cbzIn = new MarkableFileInputStream(getSourceFileOriginal());
+                       zipIn = new ZipInputStream(cbzIn);
+                       for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn
+                                       .getNextEntry()) {
+                               if (!entry.isDirectory()
+                                               && entry.getName().startsWith(getDataPrefix())) {
+                                       String entryLName = entry.getName().toLowerCase();
+                                       boolean imageEntry = false;
+                                       for (String ext : bsImages.getImageExt(false)) {
+                                               if (entryLName.endsWith(ext)) {
+                                                       imageEntry = true;
+                                               }
+                                       }
+                                       
+                                       if (imageEntry) {
+                                               String uuid = meta.getUuid() + "_" + entry.getName();
+                                               try {
+                                                       images.put(uuid, new Image(zipIn));
+                                               } catch (Exception e) {
+                                                       Instance.getInstance().getTraceHandler().error(e);
+                                               }
+
+                                               if (pg.getProgress() < 85) {
+                                                       pg.add(1);
+                                               }
+                                       } else if (entryLName.endsWith(".info")) {
+                                               basename = entryLName.substring(0, entryLName.length()
+                                                               - ".info".length());
+                                               IOUtils.write(zipIn, new File(tmpDir, entryLName));
+                                       } else if (entryLName.endsWith(".txt")) {
+                                               IOUtils.write(zipIn, new File(tmpDir, entryLName));
+                                       }
+                               }
+                       }
+                       
+                       String ext = "."
+                                       + Instance.getInstance().getConfig().getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+                       String coverName = meta.getUuid() + "_" + basename + ext;
+                       Image cover = images.get(coverName);
+                       images.remove(coverName);
+
+                       pg.setProgress(85);
+
+                       // ZIP order is not correct for us
+                       List<String> imagesList = new ArrayList<String>(images.keySet());
+                       Collections.sort(imagesList);
+
+                       pg.setProgress(90);
+
+                       // only the description/cover is kept
+                       Story origStory = getStoryFromTxt(tmpDir, basename);
+                       if (origStory != null) {
+                               if (origStory.getMeta().getCover() == null) {
+                                       origStory.getMeta().setCover(story.getMeta().getCover());
+                               }
+                               story.setMeta(origStory.getMeta());
+                       }
+                       if (story.getMeta().getCover() == null) {
+                               story.getMeta().setCover(cover);
+                       }
+                       story.setChapters(new ArrayList<Chapter>());
+
+                       // Check if we can find non-images chapters, for hybrid-cbz support
+                       if (origStory != null) {
+                               for (Chapter chap : origStory) {
+                                       Boolean isImages = null;
+                                       for (Paragraph para : chap) {
+                                               ParagraphType t = para.getType();
+                                               if (isImages == null && !t.isText(true)) {
+                                                       isImages = true;
+                                               }
+                                               if (t.isText(false)) {
+                                                       String line = para.getContent();
+                                                       // Images are saved in text mode as "[image-link]"
+                                                       if (!(line.startsWith("[") && line.endsWith("]"))) {
+                                                               isImages = false;
+                                                       }
+                                               }
+                                       }
+
+                                       if (isImages != null && !isImages) {
+                                               story.getChapters().add(chap);
+                                               chap.setNumber(story.getChapters().size());
+                                       }
+                               }
+                       }
+
+                       if (!imagesList.isEmpty()) {
+                               Chapter chap = new Chapter(story.getChapters().size() + 1, "");
+                               story.getChapters().add(chap);
+
+                               for (String uuid : imagesList) {
+                                       try {
+                                               chap.getParagraphs().add(
+                                                               new Paragraph(images.get(uuid)));
+                                       } catch (Exception e) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                       }
+                               }
+                       }
+
+                       if (meta.getCover() == null && !images.isEmpty()) {
+                               meta.setCover(images.get(imagesList.get(0)));
+                               meta.setFakeCover(true);
+                       }
+               } finally {
+                       IOUtils.deltree(tmpDir);
+                       if (zipIn != null) {
+                               zipIn.close();
+                       }
+                       if (cbzIn != null) {
+                               cbzIn.close();
+                       }
+               }
+
+               pg.done();
+               return story;
+       }
+
+       private Story getStoryFromTxt(File tmpDir, String basename) {
+               Story origStory = null;
+
+               File txt = new File(tmpDir, basename + ".txt");
+               if (!txt.exists()) {
+                       basename = null;
+               }
+               if (basename != null) {
+                       try {
+                               BasicSupport support = BasicSupport.getSupport(txt.toURI()
+                                               .toURL());
+                               origStory = support.process(null);
+                       } catch (Exception e) {
+                               basename = null;
+                       }
+               }
+
+               return origStory;
+
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/E621.java b/src/be/nikiroo/fanfix/supported/E621.java
new file mode 100644 (file)
index 0000000..adf8d28
--- /dev/null
@@ -0,0 +1,432 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.Version;
+
+/**
+ * Support class for <a href="http://e621.net/">e621.net</a> and
+ * <a href="http://e926.net/">e926.net</a>, a Furry website supporting comics,
+ * including some of MLP.
+ * <p>
+ * <a href="http://e926.net/">e926.net</a> only shows the "clean" images and
+ * comics, but it can be difficult to browse.
+ * 
+ * @author niki
+ */
+class E621 extends BasicSupport {
+       @Override
+       protected boolean supports(URL url) {
+               String host = url.getHost();
+               if (host.startsWith("www.")) {
+                       host = host.substring("www.".length());
+               }
+
+               return ("e621.net".equals(host) || "e926.net".equals(host))
+                               && (isPool(url) || isSearchOrSet(url));
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle());
+               meta.setAuthor(getAuthor());
+               meta.setDate(bsHelper.formatDate(getDate()));
+               meta.setTags(getTags());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("Furry");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover());
+               meta.setFakeCover(true);
+
+               return meta;
+       }
+
+       @Override
+       protected String getDesc() throws IOException {
+               if (isSearchOrSet(getSource())) {
+                       StringBuilder builder = new StringBuilder();
+                       builder.append("A collection of images from ")
+                                       .append(getSource().getHost()).append("\n") //
+                                       .append("\tTime of creation: "
+                                                       + StringUtils.fromTime(new Date().getTime()))
+                                       .append("\n") //
+                                       .append("\tTags: ");//
+                       for (String tag : getTags()) {
+                               builder.append("\t\t").append(tag);
+                       }
+
+                       return builder.toString();
+               }
+
+               if (isPool(getSource())) {
+                       Element el = getSourceNode().getElementById("description");
+                       if (el != null) {
+                               return el.text();
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               int i = 1;
+               String jsonUrl = getJsonUrl();
+               if (jsonUrl != null) {
+                       for (i = 1; true; i++) {
+                               if (i > 1) {
+                                       try {
+                                               // The API does not accept more than 2 request per sec,
+                                               // and asks us to limit at one per sec when possible
+                                               Thread.sleep(1000);
+                                       } catch (InterruptedException e) {
+                                       }
+                               }
+
+                               try {
+                                       JSONObject json = getJson(jsonUrl + "&page=" + i, false);
+                                       if (!json.has("posts"))
+                                               break;
+                                       JSONArray posts = json.getJSONArray("posts");
+                                       if (posts.isEmpty())
+                                               break;
+                               } catch (Exception e) {
+                                       e.printStackTrace();
+                               }
+                       }
+
+                       // The last page was empty:
+                       i--;
+               }
+
+               // The pages and images are in reverse order on /posts/
+               List<Entry<String, URL>> chapters = new LinkedList<Entry<String, URL>>();
+               for (int page = i; page > 0; page--) {
+                       chapters.add(new AbstractMap.SimpleEntry<String, URL>(
+                                       "Page " + Integer.toString(i - page + 1),
+                                       new URL(jsonUrl + "&page=" + page)));
+               }
+
+               return chapters;
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
+               StringBuilder builder = new StringBuilder();
+
+               JSONObject json = getJson(chapUrl, false);
+               JSONArray postsArr = json.getJSONArray("posts");
+
+               // The pages and images are in reverse order on /posts/
+               List<JSONObject> posts = new ArrayList<JSONObject>(postsArr.length());
+               for (int i = postsArr.length() - 1; i >= 0; i--) {
+                       Object o = postsArr.get(i);
+                       if (o instanceof JSONObject)
+                               posts.add((JSONObject) o);
+               }
+
+               for (JSONObject post : posts) {
+                       if (!post.has("file"))
+                               continue;
+                       JSONObject file = post.getJSONObject("file");
+                       if (!file.has("url"))
+                               continue;
+
+                       try {
+                               String url = file.getString("url");
+                               builder.append("[");
+                               builder.append(url);
+                               builder.append("]<br/>");
+                       } catch (JSONException e) {
+                               // Can be NULL if filtered
+                               // When the value is NULL, we get an exception
+                               // but the "has" method still returns true
+                               Instance.getInstance().getTraceHandler()
+                                               .error("Cannot get image for chapter " + number + " of "
+                                                               + getSource());
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected URL getCanonicalUrl(URL source) {
+               // Convert search-pools into proper pools
+               if (source.getPath().equals("/posts") && source.getQuery() != null
+                               && source.getQuery().startsWith("tags=pool%3A")) {
+                       String poolNumber = source.getQuery()
+                                       .substring("tags=pool%3A".length());
+                       try {
+                               Integer.parseInt(poolNumber);
+                               String base = source.getProtocol() + "://" + source.getHost();
+                               if (source.getPort() != -1) {
+                                       base = base + ":" + source.getPort();
+                               }
+                               source = new URL(base + "/pools/" + poolNumber);
+                       } catch (NumberFormatException e) {
+                               // Not a simple pool, skip
+                       } catch (MalformedURLException e) {
+                               // Cannot happen
+                       }
+               }
+
+               if (isSetOriginalUrl(source)) {
+                       try {
+                               Document doc = DataUtil.load(Instance.getInstance().getCache()
+                                               .open(source, this, false), "UTF-8", source.toString());
+                               for (Element shortname : doc
+                                               .getElementsByClass("set-shortname")) {
+                                       for (Element el : shortname.getElementsByTag("a")) {
+                                               if (!el.attr("href").isEmpty())
+                                                       return new URL(el.absUrl("href"));
+                                       }
+                               }
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+               }
+
+               if (isPool(source)) {
+                       try {
+                               return new URL(
+                                               source.toString().replace("/pool/show/", "/pools/"));
+                       } catch (MalformedURLException e) {
+                       }
+               }
+
+               return super.getCanonicalUrl(source);
+       }
+
+       private String getTitle() {
+               String title = "";
+
+               Element el = getSourceNode().getElementsByTag("title").first();
+               if (el != null) {
+                       title = el.text().trim();
+               }
+
+               for (String s : new String[] { "e621", "-", "e621", "Pool", "-" }) {
+                       if (title.startsWith(s)) {
+                               title = title.substring(s.length()).trim();
+                       }
+                       if (title.endsWith(s)) {
+                               title = title.substring(0, title.length() - s.length()).trim();
+                       }
+               }
+
+               if (isSearchOrSet(getSource())) {
+                       title = title.isEmpty() ? "e621" : "[e621] " + title;
+               }
+
+               return title;
+       }
+
+       private String getAuthor() {
+               List<String> list = new ArrayList<String>();
+               String jsonUrl = getJsonUrl();
+               if (jsonUrl != null) {
+                       try {
+                               JSONObject json = getJson(jsonUrl, false);
+                               JSONArray posts = json.getJSONArray("posts");
+                               for (Object obj : posts) {
+                                       if (!(obj instanceof JSONObject))
+                                               continue;
+
+                                       JSONObject post = (JSONObject) obj;
+                                       if (!post.has("tags"))
+                                               continue;
+
+                                       JSONObject tags = post.getJSONObject("tags");
+                                       if (!tags.has("artist"))
+                                               continue;
+
+                                       JSONArray artists = tags.getJSONArray("artist");
+                                       for (Object artist : artists) {
+                                               if (list.contains(artist.toString()))
+                                                       continue;
+
+                                               list.add(artist.toString());
+                                       }
+                               }
+                       } catch (Exception e) {
+                               e.printStackTrace();
+                       }
+               }
+
+               StringBuilder builder = new StringBuilder();
+               for (String artist : list) {
+                       if (builder.length() > 0) {
+                               builder.append(", ");
+                       }
+                       builder.append(artist);
+               }
+
+               return builder.toString();
+       }
+
+       private String getDate() {
+               String jsonUrl = getJsonUrl();
+               if (jsonUrl != null) {
+                       try {
+                               JSONObject json = getJson(jsonUrl, false);
+                               JSONArray posts = json.getJSONArray("posts");
+                               for (Object obj : posts) {
+                                       if (!(obj instanceof JSONObject))
+                                               continue;
+
+                                       JSONObject post = (JSONObject) obj;
+                                       if (!post.has("created_at"))
+                                               continue;
+
+                                       return post.getString("created_at");
+                               }
+                       } catch (Exception e) {
+                               e.printStackTrace();
+                       }
+               }
+
+               return "";
+       }
+
+       // no tags for pools
+       private List<String> getTags() {
+               List<String> tags = new ArrayList<String>();
+               if (isSearchOrSet(getSource())) {
+                       String str = getTagsFromUrl(getSource());
+                       for (String tag : str.split("\\+")) {
+                               try {
+                                       tags.add(URLDecoder.decode(tag.trim(), "UTF-8").trim());
+                               } catch (UnsupportedEncodingException e) {
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       // returns "xxx+ddd+ggg" if "tags=xxx+ddd+ggg" was present in the query
+       private String getTagsFromUrl(URL url) {
+               String tags = url == null ? "" : url.getQuery();
+               int pos = tags.indexOf("tags=");
+
+               if (pos >= 0) {
+                       tags = tags.substring(pos).substring("tags=".length());
+               } else {
+                       return "";
+               }
+
+               pos = tags.indexOf('&');
+               if (pos > 0) {
+                       tags = tags.substring(0, pos);
+               }
+               pos = tags.indexOf('/');
+               if (pos > 0) {
+                       tags = tags.substring(0, pos);
+               }
+
+               return tags;
+       }
+
+       private Image getCover() throws IOException {
+               Image image = null;
+               List<Entry<String, URL>> chapters = getChapters(null);
+               if (!chapters.isEmpty()) {
+                       URL chap1Url = chapters.get(0).getValue();
+                       String imgsChap1 = getChapterContent(chap1Url, 1, null);
+                       if (!imgsChap1.isEmpty()) {
+                               imgsChap1 = imgsChap1.split("]")[0].substring(1).trim();
+                               image = bsImages.getImage(this, new URL(imgsChap1));
+                       }
+               }
+
+               return image;
+       }
+
+       // always /posts.json/ url
+       private String getJsonUrl() {
+               String url = null;
+               if (isSearchOrSet(getSource())) {
+                       url = getSource().toString().replace("/posts", "/posts.json");
+               }
+
+               if (isPool(getSource())) {
+                       String poolNumber = getSource().getPath()
+                                       .substring("/pools/".length());
+                       url = "https://e621.net/posts.json" + "?tags=pool%3A" + poolNumber;
+               }
+
+               if (url != null) {
+                       // Note: one way to override the blacklist
+                       String login = Instance.getInstance().getConfig()
+                                       .getString(Config.LOGIN_E621_LOGIN);
+                       String apk = Instance.getInstance().getConfig()
+                                       .getString(Config.LOGIN_E621_APIKEY);
+
+                       if (login != null && !login.isEmpty() && apk != null
+                                       && !apk.isEmpty()) {
+                               url = String.format("%s&login=%s&api_key=%s&_client=%s", url,
+                                               login, apk, "fanfix-" + Version.getCurrentVersion());
+                       }
+               }
+
+               return url;
+       }
+
+       // note: will be removed at getCanonicalUrl()
+       private boolean isSetOriginalUrl(URL originalUrl) {
+               return originalUrl.getPath().startsWith("/post_sets/");
+       }
+
+       private boolean isPool(URL url) {
+               return url.getPath().startsWith("/pools/")
+                               || url.getPath().startsWith("/pool/show/");
+       }
+
+       // set will be renamed into search by canonical url
+       private boolean isSearchOrSet(URL url) {
+               return
+               // search:
+               (url.getPath().equals("/posts") && url.getQuery().contains("tags="))
+                               // or set:
+                               || isSetOriginalUrl(url);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/EHentai.java b/src/be/nikiroo/fanfix/supported/EHentai.java
new file mode 100644 (file)
index 0000000..3c73432
--- /dev/null
@@ -0,0 +1,292 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="https://e-hentai.org/">e-hentai.org</a>, a website
+ * supporting mostly but not always NSFW comics, including some of MLP.
+ * 
+ * @author niki
+ */
+class EHentai extends BasicSupport_Deprecated {
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate(getDate(reset(in)));
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang(getLang(reset(in)));
+               meta.setSubject("Hentai");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover(source, reset(in)));
+               meta.setFakeCover(true);
+
+               return meta;
+       }
+
+       @Override
+       public Story process(URL url, Progress pg) throws IOException {
+               // There is no chapters on e621, just pagination...
+               Story story = super.process(url, pg);
+
+               Chapter only = new Chapter(1, "");
+               for (Chapter chap : story) {
+                       only.getParagraphs().addAll(chap.getParagraphs());
+               }
+
+               story.getChapters().clear();
+               story.getChapters().add(only);
+
+               return story;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "e-hentai.org".equals(url.getHost());
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       public Map<String, String> getCookies() {
+               Map<String, String> cookies = super.getCookies();
+               cookies.put("nw", "1");
+               return cookies;
+       }
+
+       private Image getCover(URL source, InputStream in) {
+               Image author = null;
+               String coverLine = getKeyLine(in, "<div id=\"gd1\"", " url(", ")");
+               if (coverLine != null) {
+                       coverLine = StringUtils.unhtml(coverLine).trim();
+                       author = getImage(this, source, coverLine);
+               }
+
+               return author;
+       }
+
+       private String getAuthor(InputStream in) {
+               String author = null;
+
+               List<String> tagsAuthor = getTagsAuthor(in);
+               if (!tagsAuthor.isEmpty()) {
+                       author = tagsAuthor.get(0);
+               }
+
+               return author;
+       }
+
+       private String getLang(InputStream in) {
+               String lang = null;
+
+               String langLine = getKeyLine(in, "class=\"gdt1\">Language",
+                               "class=\"gdt2\"", "</td>");
+               if (langLine != null) {
+                       langLine = StringUtils.unhtml(langLine).trim();
+                       if (langLine.equalsIgnoreCase("English")) {
+                               lang = "en";
+                       } else if (langLine.equalsIgnoreCase("Japanese")) {
+                               lang = "jp";
+                       } else if (langLine.equalsIgnoreCase("French")) {
+                               lang = "fr";
+                       } else {
+                               // TODO find the code for other languages?
+                               lang = langLine;
+                       }
+               }
+
+               return lang;
+       }
+
+       private String getDate(InputStream in) {
+               String date = null;
+
+               String dateLine = getKeyLine(in, "class=\"gdt1\">Posted",
+                               "class=\"gdt2\"", "</td>");
+               if (dateLine != null) {
+                       dateLine = StringUtils.unhtml(dateLine).trim();
+                       if (dateLine.length() > 10) {
+                               dateLine = dateLine.substring(0, 10).trim();
+                       }
+
+                       date = dateLine;
+               }
+
+               return date;
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+               List<String> tagsAuthor = getTagsAuthor(in);
+
+               for (int i = 1; i < tagsAuthor.size(); i++) {
+                       tags.add(tagsAuthor.get(i));
+               }
+
+               return tags;
+       }
+
+       private List<String> getTagsAuthor(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+               String tagLine = getKeyLine(in, "<meta name=\"description\"", "Tags: ",
+                               null);
+               if (tagLine != null) {
+                       for (String tag : tagLine.split(",")) {
+                               String candi = tag.trim();
+                               if (!candi.isEmpty() && !tags.contains(candi)) {
+                                       tags.add(candi);
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       private String getTitle(InputStream in) {
+               String siteName = " - E-Hentai Galleries";
+
+               String title = getLine(in, "<title>", 0);
+               if (title != null) {
+                       title = StringUtils.unhtml(title).trim();
+                       if (title.endsWith(siteName)) {
+                               title = title.substring(0, title.length() - siteName.length())
+                                               .trim();
+                       }
+               }
+
+               return title;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               String desc = null;
+
+               String descLine = getKeyLine(in, "Uploader Comment", null,
+                               "<div class=\"c7\"");
+               if (descLine != null) {
+                       desc = StringUtils.unhtml(descLine);
+               }
+
+               return desc;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               int last = 0; // no pool/show when only one page, first page == page 0
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter(">");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains(source.toString())) {
+                               String page = line.substring(line.indexOf(source.toString()));
+                               String pkey = "?p=";
+                               if (page.contains(pkey)) {
+                                       page = page.substring(page.indexOf(pkey) + pkey.length());
+                                       String number = "";
+                                       while (!page.isEmpty() && page.charAt(0) >= '0'
+                                                       && page.charAt(0) <= '9') {
+                                               number += page.charAt(0);
+                                               page = page.substring(1);
+                                       }
+                                       if (number.isEmpty()) {
+                                               number = "0";
+                                       }
+
+                                       int current = Integer.parseInt(number);
+                                       if (last < current) {
+                                               last = current;
+                                       }
+                               }
+                       }
+               }
+
+               for (int i = 0; i <= last; i++) {
+                       urls.add(new AbstractMap.SimpleEntry<String, URL>(Integer
+                                       .toString(i + 1), new URL(source.toString() + "?p=" + i)));
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) throws IOException {
+               String staticSite = "https://e-hentai.org/s/";
+               List<URL> pages = new ArrayList<URL>();
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\"");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.startsWith(staticSite)) {
+                               try {
+                                       pages.add(new URL(line));
+                               } catch (MalformedURLException e) {
+                                       Instance.getInstance().getTraceHandler()
+                                                       .error(new IOException("Parsing error, a link is not correctly parsed: " + line, e));
+                               }
+                       }
+               }
+
+               if (pg == null) {
+                       pg = new Progress();
+               }
+               pg.setMinMax(0, pages.size());
+               pg.setProgress(0);
+
+               StringBuilder builder = new StringBuilder();
+
+               for (URL page : pages) {
+                       InputStream pageIn = Instance.getInstance().getCache().open(page, this, false);
+                       try {
+                               String link = getKeyLine(pageIn, "id=\"img\"", "src=\"", "\"");
+                               if (link != null && !link.isEmpty()) {
+                                       builder.append("[");
+                                       builder.append(link);
+                                       builder.append("]<br/>");
+                               }
+                               pg.add(1);
+                       } finally {
+                               if (pageIn != null) {
+                                       pageIn.close();
+                               }
+                       }
+               }
+
+               pg.done();
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Epub.java b/src/be/nikiroo/fanfix/supported/Epub.java
new file mode 100644 (file)
index 0000000..965a27a
--- /dev/null
@@ -0,0 +1,260 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.StringUtils;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for EPUB files created with this program (as we need some
+ * metadata available in those we create).
+ * 
+ * @author niki
+ */
+class Epub extends InfoText {
+       private MetaData meta;
+       private File tmpDir;
+       private String desc;
+
+       private URL fakeSource;
+       private InputStream fakeIn;
+
+       public File getSourceFileOriginal() {
+               return super.getSourceFile();
+       }
+
+       @Override
+       protected File getSourceFile() {
+               try {
+                       return new File(fakeSource.toURI());
+               } catch (URISyntaxException e) {
+                       Instance.getInstance().getTraceHandler().error(new IOException(
+                                       "Cannot get the source file from the info-text URL", e));
+               }
+
+               return null;
+       }
+
+       @Override
+       protected InputStream getInput() {
+               if (fakeIn != null) {
+                       try {
+                               fakeIn.reset();
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(new IOException(
+                                               "Cannot reset the Epub Text stream", e));
+                       }
+
+                       return fakeIn;
+               }
+
+               return null;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return url.getPath().toLowerCase().endsWith(".epub");
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               return meta;
+       }
+
+       @Override
+       protected Document loadDocument(URL source) throws IOException {
+               super.loadDocument(source); // prepares super.getSourceFile() and
+                                                                       // super.getInput()
+
+               InputStream in = super.getInput();
+               ZipInputStream zipIn = null;
+               try {
+                       zipIn = new ZipInputStream(in);
+                       tmpDir = Instance.getInstance().getTempFiles()
+                                       .createTempDir("fanfic-reader-parser");
+                       File tmp = new File(tmpDir, "file.txt");
+                       File tmpInfo = new File(tmpDir, "file.info");
+
+                       fakeSource = tmp.toURI().toURL();
+                       Image cover = null;
+
+                       String url;
+                       try {
+                               url = getSource().toURI().toURL().toString();
+                       } catch (URISyntaxException e1) {
+                               url = getSource().toString();
+                       }
+                       String title = null;
+                       String author = null;
+
+                       for (ZipEntry entry = zipIn
+                                       .getNextEntry(); entry != null; entry = zipIn
+                                                       .getNextEntry()) {
+                               if (!entry.isDirectory()
+                                               && entry.getName().startsWith(getDataPrefix())) {
+                                       String entryLName = entry.getName().toLowerCase();
+                                       entryLName = entryLName.substring(getDataPrefix().length());
+
+                                       boolean imageEntry = false;
+                                       for (String ext : bsImages.getImageExt(false)) {
+                                               if (entryLName.endsWith(ext)) {
+                                                       imageEntry = true;
+                                               }
+                                       }
+
+                                       if (entryLName.equals("version")) {
+                                               // Nothing to do for now ("first"
+                                               // version is 3.0)
+                                       } else if (entryLName.endsWith(".info")) {
+                                               // Info file
+                                               IOUtils.write(zipIn, tmpInfo);
+                                       } else if (imageEntry) {
+                                               // Cover
+                                               if (getCover() && cover == null) {
+                                                       try {
+                                                               cover = new Image(zipIn);
+                                                       } catch (Exception e) {
+                                                               Instance.getInstance().getTraceHandler()
+                                                                               .error(e);
+                                                       }
+                                               }
+                                       } else if (entryLName.equals("url")) {
+                                               String[] descArray = StringUtils
+                                                               .unhtml(IOUtils.readSmallStream(zipIn)).trim()
+                                                               .split("\n");
+                                               if (descArray.length > 0) {
+                                                       url = descArray[0].trim();
+                                               }
+                                       } else if (entryLName.endsWith(".desc")) {
+                                               // // For old files
+                                               // if (this.desc != null) {
+                                               // this.desc = IOUtils.readSmallStream(zipIn).trim();
+                                               // }
+                                       } else if (entryLName.equals("summary")) {
+                                               String[] descArray = StringUtils
+                                                               .unhtml(IOUtils.readSmallStream(zipIn)).trim()
+                                                               .split("\n");
+                                               int skip = 0;
+                                               if (descArray.length > 1) {
+                                                       title = descArray[0].trim();
+                                                       skip = 1;
+                                                       if (descArray.length > 2
+                                                                       && descArray[1].startsWith("©")) {
+                                                               author = descArray[1].substring(1).trim();
+                                                               skip = 2;
+                                                       }
+                                               }
+                                               // this.desc = "";
+                                               // for (int i = skip; i < descArray.length; i++) {
+                                               // this.desc += descArray[i].trim() + "\n";
+                                               // }
+                                               //
+                                               // this.desc = this.desc.trim();
+                                       } else {
+                                               // Hopefully the data file
+                                               IOUtils.write(zipIn, tmp);
+                                       }
+                               }
+                       }
+
+                       if (requireInfo() && !tmp.exists()) {
+                               throw new IOException(
+                                               "file not supported (maybe not created with this program or corrupt)");
+                       }
+
+                       if (tmp.exists()) {
+                               this.fakeIn = new MarkableFileInputStream(tmp);
+                       }
+
+                       if (tmpInfo.exists()) {
+                               meta = InfoReader.readMeta(tmpInfo, true);
+                               tmpInfo.delete();
+                       } else {
+                               if (title == null || title.isEmpty()) {
+                                       title = getSourceFileOriginal().getName();
+                                       String exts[] = new String[] {".epub", ".cbz"};
+                                       for (String ext : exts) {
+                                               if (title.toLowerCase().endsWith(ext)) {
+                                                       title = title.substring(0,
+                                                                       title.length() - ext.length());
+                                               }
+                                       }
+                                       title = URLDecoder.decode(title, "UTF-8").trim();
+                               }
+
+                               meta = new MetaData();
+                               meta.setLang("en");
+                               meta.setTags(Arrays.asList("[no_info]"));
+                               meta.setSource(getType().getSourceName());
+                               meta.setUuid(url);
+                               meta.setUrl(url);
+                               meta.setTitle(title);
+                               meta.setAuthor(author);
+                               meta.setImageDocument(isImagesDocumentByDefault());
+                               
+                               InfoReader.completeMeta(tmp, meta);
+                       }
+
+                       if (meta.getCover() == null) {
+                               if (cover != null) {
+                                       meta.setCover(cover);
+                               } else {
+                                       meta.setCover(InfoReader.getCoverByName(
+                                                       getSourceFileOriginal().toURI().toURL()));
+                               }
+                       }
+               } finally {
+                       if (zipIn != null) {
+                               zipIn.close();
+                       }
+                       if (in != null) {
+                               in.close();
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected void close() {
+               if (tmpDir != null) {
+                       IOUtils.deltree(tmpDir);
+               }
+
+               tmpDir = null;
+
+               super.close();
+       }
+
+       protected String getDataPrefix() {
+               return "DATA/";
+       }
+
+       protected boolean requireInfo() {
+               return true;
+       }
+
+       protected boolean getCover() {
+               return true;
+       }
+
+       protected boolean isImagesDocumentByDefault() {
+               return false;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Fanfiction.java b/src/be/nikiroo/fanfix/supported/Fanfiction.java
new file mode 100644 (file)
index 0000000..16b44f8
--- /dev/null
@@ -0,0 +1,331 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.SimpleDateFormat;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://www.fanfiction.net/">Faniction.net</a>
+ * stories, a website dedicated to fanfictions of many, many different
+ * universes, from TV shows to novels to games.
+ * 
+ * @author niki
+ */
+class Fanfiction extends BasicSupport_Deprecated {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate(getDate(reset(in)));
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en"); // TODO find language of book
+               meta.setSubject(getSubject(reset(in)));
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(source, reset(in)));
+
+               return meta;
+       }
+
+       private String getSubject(InputStream in) {
+               String line = getLine(in, "id=pre_story_links", 0);
+               if (line != null) {
+                       int pos = line.lastIndexOf('"');
+                       if (pos >= 1) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('<');
+                               if (pos >= 0) {
+                                       return StringUtils.unhtml(line.substring(0, pos)).trim();
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+
+               String key = "title=\"Send Private Message\"";
+               String line = getLine(in, key, 2);
+               if (line != null) {
+                       key = "Rated:";
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               key = "Chapters:";
+                               pos = line.indexOf(key);
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos);
+                                       line = StringUtils.unhtml(line).trim();
+                                       if (line.endsWith("-")) {
+                                               line = line.substring(0, line.length() - 1);
+                                       }
+
+                                       for (String tag : line.split("-")) {
+                                               tags.add(StringUtils.unhtml(tag).trim());
+                                       }
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       private String getTitle(InputStream in) {
+               int i = 0;
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("xcontrast_txt")) {
+                               if ((++i) == 2) {
+                                       line = StringUtils.unhtml(line).trim();
+                                       if (line.startsWith("Follow/Fav")) {
+                                               line = line.substring("Follow/Fav".length()).trim();
+                                       }
+
+                                       return StringUtils.unhtml(line).trim();
+                               }
+                       }
+               }
+
+               return "";
+       }
+
+       private String getAuthor(InputStream in) {
+               String author = null;
+
+               int i = 0;
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("xcontrast_txt")) {
+                               if ((++i) == 3) {
+                                       author = StringUtils.unhtml(line).trim();
+                                       break;
+                               }
+                       }
+               }
+
+               return bsHelper.fixAuthor(author);
+       }
+
+       private String getDate(InputStream in) {
+               String key = "Published: <span data-xutime='";
+               String line = getLine(in, key, 0);
+               if (line != null) {
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               pos = line.indexOf('\'');
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos).trim();
+                                       try {
+                                               SimpleDateFormat sdf = new SimpleDateFormat(
+                                                               "yyyy-MM-dd");
+                                               return sdf
+                                                               .format(new Date(1000 * Long.parseLong(line)));
+                                       } catch (NumberFormatException e) {
+                                               Instance.getInstance().getTraceHandler()
+                                                               .error(new IOException("Cannot convert publication date: " + line, e));
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) {
+               return getLine(in, "title=\"Send Private Message\"", 1);
+       }
+
+       private Image getCover(URL url, InputStream in) {
+               String key = "class='cimage";
+               String line = getLine(in, key, 0);
+               if (line != null) {
+                       int pos = line.indexOf(key);
+                       if (pos >= 0) {
+                               line = line.substring(pos + key.length());
+                               key = "src='";
+                               pos = line.indexOf(key);
+                               if (pos >= 0) {
+                                       line = line.substring(pos + key.length());
+                                       pos = line.indexOf('\'');
+                                       if (pos >= 0) {
+                                               line = line.substring(0, pos);
+                                               if (line.startsWith("//")) {
+                                                       line = url.getProtocol() + "://"
+                                                                       + line.substring(2);
+                                               } else if (line.startsWith("//")) {
+                                                       line = url.getProtocol() + "://" + url.getHost()
+                                                                       + "/" + line.substring(1);
+                                               } else {
+                                                       line = url.getProtocol() + "://" + url.getHost()
+                                                                       + "/" + url.getPath() + "/" + line;
+                                               }
+
+                                               return getImage(this, null, line);
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               String base = source.toString();
+               int pos = base.lastIndexOf('/');
+               String suffix = base.substring(pos); // including '/' at start
+               base = base.substring(0, pos);
+               if (base.endsWith("/1")) {
+                       base = base.substring(0, base.length() - 1); // including '/' at end
+               }
+
+               String line = getLine(in, "id=chap_select", 0);
+               String key = "<option  value=";
+               int i = 1;
+
+               if (line != null) {
+                       for (pos = line.indexOf(key); pos >= 0; pos = line
+                                       .indexOf(key, pos), i++) {
+                               pos = line.indexOf('>', pos);
+                               if (pos >= 0) {
+                                       int endOfName = line.indexOf('<', pos);
+                                       if (endOfName >= 0) {
+                                               String name = line.substring(pos + 1, endOfName);
+                                               String chapNum = i + ".";
+                                               if (name.startsWith(chapNum)) {
+                                                       name = name.substring(chapNum.length(),
+                                                                       name.length());
+                                               }
+
+                                               try {
+                                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(
+                                                                       name.trim(), new URL(base + i + suffix)));
+                                               } catch (MalformedURLException e) {
+                                                       Instance.getInstance().getTraceHandler().error(
+                                                                       new IOException("Cannot parse chapter " + i + " url: " + (base + i + suffix), e));
+                                               }
+                                       }
+                               }
+                       }
+               } else {
+                       // only one chapter:
+                       final String chapName = getTitle(reset(in));
+                       final URL chapURL = source;
+                       urls.add(new Entry<String, URL>() {
+                               @Override
+                               public URL setValue(URL value) {
+                                       return null;
+                               }
+
+                               @Override
+                               public URL getValue() {
+                                       return chapURL;
+                               }
+
+                               @Override
+                               public String getKey() {
+                                       return chapName;
+                               }
+                       });
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) {
+               StringBuilder builder = new StringBuilder();
+               String startAt = "class='storytext ";
+               String endAt1 = "function review_init";
+               String endAt2 = "id=chap_select";
+               boolean ok = false;
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (!ok && line.contains(startAt)) {
+                               ok = true;
+                       } else if (ok && (line.contains(endAt1) || line.contains(endAt2))) {
+                               ok = false;
+                               break;
+                       }
+
+                       if (ok) {
+                               // First line may contain the title and chap name again
+                               if (builder.length() == 0) {
+                                       int pos = line.indexOf("<hr");
+                                       if (pos >= 0) {
+                                               boolean chaptered = false;
+                                               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
+                                                       String chapterWord = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER,
+                                                                       lang);
+                                                       int posChap = line.indexOf(chapterWord + " ");
+                                                       if (posChap < pos) {
+                                                               chaptered = true;
+                                                               break;
+                                                       }
+                                               }
+
+                                               if (chaptered) {
+                                                       line = line.substring(pos);
+                                               }
+                                       }
+                               }
+
+                               builder.append(line);
+                               builder.append(' ');
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "fanfiction.net".equals(url.getHost())
+                               || "www.fanfiction.net".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Fimfiction.java b/src/be/nikiroo/fanfix/supported/Fimfiction.java
new file mode 100644 (file)
index 0000000..77267c1
--- /dev/null
@@ -0,0 +1,253 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a>
+ * stories, a website dedicated to My Little Pony.
+ * 
+ * @author niki
+ */
+class Fimfiction extends BasicSupport_Deprecated {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate(getDate(reset(in)));
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("MLP");
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(reset(in)));
+
+               return meta;
+       }
+
+       @Override
+       public Map<String, String> getCookies() {
+               Map<String, String> cookies = new HashMap<String, String>();
+               cookies.put("view_mature", "true");
+               return cookies;
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+               tags.add("MLP");
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               boolean started = false;
+               while (scan.hasNext()) {
+                       String line = scan.next();
+
+                       if (!started) {
+                               started = line.contains("\"story_container\"");
+                       }
+
+                       if (started && line.contains("class=\"tag-")) {
+                               if (line.contains("index.php")) {
+                                       break; // end of *this story* tags
+                               }
+
+                               String keyword = "title=\"";
+                               Scanner tagScanner = new Scanner(line);
+                               tagScanner.useDelimiter(keyword);
+                               if (tagScanner.hasNext()) {
+                                       tagScanner.next();// Ignore first one
+                               }
+                               while (tagScanner.hasNext()) {
+                                       String tag = tagScanner.next();
+                                       if (tag.contains("\"")) {
+                                               tag = tag.split("\"")[0];
+                                               tag = StringUtils.unhtml(tag).trim();
+                                               if (!tag.isEmpty() && !tags.contains(tag)) {
+                                                       tags.add(tag);
+                                               }
+                                       }
+                               }
+                               tagScanner.close();
+                       }
+               }
+
+               return tags;
+       }
+
+       private String getTitle(InputStream in) {
+               String line = getLine(in, " property=\"og:title\"", 0);
+               if (line != null) {
+                       int pos = -1;
+                       for (int i = 0; i < 3; i++) {
+                               pos = line.indexOf('"', pos + 1);
+                       }
+
+                       if (pos >= 0) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('"');
+                               if (pos >= 0) {
+                                       return StringUtils.unhtml(line.substring(0, pos)).trim();
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getAuthor(InputStream in) {
+               String line = getLine(in, " href=\"/user/", 0);
+               if (line != null) {
+                       int pos = line.indexOf('"');
+                       if (pos >= 0) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('"');
+                               if (pos >= 0) {
+                                       line = line.substring(0, pos);
+                                       pos = line.lastIndexOf('/');
+                                       if (pos >= 0) {
+                                               line = line.substring(pos + 1);
+                                               return line.replace('+', ' ');
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getDate(InputStream in) {
+               String line = getLine(in, "<span class=\"date\">", 0);
+               if (line != null) {
+                       int pos = -1;
+                       for (int i = 0; i < 3; i++) {
+                               pos = line.indexOf('>', pos + 1);
+                       }
+
+                       if (pos >= 0) {
+                               line = line.substring(pos + 1);
+                               pos = line.indexOf('<');
+                               if (pos >= 0) {
+                                       return line.substring(0, pos).trim();
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) {
+               // the og: meta version is the SHORT resume, this is the LONG resume
+               return getLine(in, "class=\"description-text bbcode\"", 1);
+       }
+
+       private Image getCover(InputStream in) {
+               // Note: the 'og:image' is the SMALL cover, not the full version
+               String cover = getLine(in, "class=\"story_container__story_image\"", 1);
+               if (cover != null) {
+                       int pos = cover.indexOf('"');
+                       if (pos >= 0) {
+                               cover = cover.substring(pos + 1);
+                               pos = cover.indexOf('"');
+                               if (pos >= 0) {
+                                       cover = cover.substring(0, pos);
+                               }
+                       }
+               }
+
+               return getImage(this, null, cover);
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               boolean started = false;
+               while (scan.hasNext()) {
+                       String line = scan.next().trim();
+
+                       if (!started) {
+                               started = line.equals("<!--Chapters-->");
+                       } else {
+                               if (line.equals("</form>")) {
+                                       break;
+                               }
+
+                               if (line.startsWith("<a href=")
+                                               || line.contains("class=\"chapter-title\"")) {
+                                       // Chapter name
+                                       String name = line;
+                                       int pos = name.indexOf('>');
+                                       if (pos >= 0) {
+                                               name = name.substring(pos + 1);
+                                               pos = name.indexOf('<');
+                                               if (pos >= 0) {
+                                                       name = name.substring(0, pos);
+                                               }
+                                       }
+                                       // Chapter content
+                                       pos = line.indexOf('/');
+                                       if (pos >= 0) {
+                                               line = line.substring(pos); // we take the /, not +1
+                                               pos = line.indexOf('"');
+                                               if (pos >= 0) {
+                                                       line = line.substring(0, pos);
+                                               }
+                                       }
+
+                                       try {
+                                               urls.add(new AbstractMap.SimpleEntry<String, URL>(name,
+                                                               new URL("http://www.fimfiction.net" + line)));
+                                       } catch (MalformedURLException e) {
+                                               Instance.getInstance().getTraceHandler().error(e);
+                                       }
+                               }
+                       }
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) {
+               return getLine(in, "<div class=\"bbcode\">", 1);
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "fimfiction.net".equals(url.getHost())
+                               || "www.fimfiction.net".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/FimfictionApi.java b/src/be/nikiroo/fanfix/supported/FimfictionApi.java
new file mode 100644 (file)
index 0000000..43d01d1
--- /dev/null
@@ -0,0 +1,417 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * Support class for <a href="http://www.fimfiction.net/">FimFiction.net</a>
+ * stories, a website dedicated to My Little Pony.
+ * <p>
+ * This version uses the new, official API of FimFiction.
+ * 
+ * @author niki
+ */
+class FimfictionApi extends BasicSupport {
+       private String oauth;
+       private String json;
+
+       private Map<Integer, String> chapterNames;
+       private Map<Integer, String> chapterContents;
+
+       public FimfictionApi() throws IOException {
+               if (Instance.getInstance().getConfig().getBoolean(Config.LOGIN_FIMFICTION_APIKEY_FORCE_HTML, false)) {
+                       throw new IOException("Configuration is set to force HTML scrapping");
+               }
+
+               String oauth = Instance.getInstance().getConfig().getString(Config.LOGIN_FIMFICTION_APIKEY_TOKEN);
+
+               if (oauth == null || oauth.isEmpty()) {
+                       String clientId = Instance.getInstance().getConfig().getString(Config.LOGIN_FIMFICTION_APIKEY_CLIENT_ID)
+                                       + "";
+                       String clientSecret = Instance.getInstance().getConfig()
+                                       .getString(Config.LOGIN_FIMFICTION_APIKEY_CLIENT_SECRET) + "";
+
+                       if (clientId.trim().isEmpty() || clientSecret.trim().isEmpty()) {
+                               throw new IOException("API key required for the beta API v2");
+                       }
+
+                       oauth = generateOAuth(clientId, clientSecret);
+
+                       Instance.getInstance().getConfig().setString(Config.LOGIN_FIMFICTION_APIKEY_TOKEN, oauth);
+                       Instance.getInstance().getConfig().updateFile();
+               }
+
+               this.oauth = oauth;
+       }
+
+       @Override
+       protected Document loadDocument(URL source) throws IOException {
+               json = getJsonData();
+               return null;
+       }
+
+       @Override
+       public String getOAuth() {
+               return oauth;
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       /**
+        * Extract the full JSON data we will later use to build the {@link Story}.
+        * 
+        * @return the data in a JSON format
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private String getJsonData() throws IOException {
+               // extract the ID from:
+               // https://www.fimfiction.net/story/123456/name-of-story
+               String storyId = getKeyText(getSource().toString(), "/story/", null,
+                               "/");
+
+               // Selectors, so to download all I need and only what I need
+               String storyContent = "fields[story]=title,description,date_published,cover_image";
+               String authorContent = "fields[author]=name";
+               String chapterContent = "fields[chapter]=chapter_number,title,content_html,authors_note_html";
+               String includes = "author,chapters,tags";
+
+               String urlString = String.format(
+                               "https://www.fimfiction.net/api/v2/stories/%s?" //
+                                               + "%s&%s&%s&" //
+                                               + "include=%s", //
+                               storyId, //
+                               storyContent, authorContent, chapterContent,//
+                               includes);
+
+               // URL params must be URL-encoded: "[ ]" <-> "%5B %5D"
+               urlString = urlString.replace("[", "%5B").replace("]", "%5D");
+
+               URL url = new URL(urlString);
+               InputStream jsonIn = Instance.getInstance().getCache().open(url, this, false);
+               try {
+                       return IOUtils.readSmallStream(jsonIn);
+               } finally {
+                       jsonIn.close();
+               }
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getKeyJson(json, 0, "type", "story", "title"));
+               meta.setAuthor(getKeyJson(json, 0, "type", "user", "name"));
+               meta.setDate(bsHelper.formatDate(
+                               getKeyJson(json, 0, "type", "story", "date_published")));
+               meta.setTags(getTags());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("MLP");
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+
+               String coverImageLink = getKeyJson(json, 0, "type", "story",
+                               "cover_image", "full");
+               if (!coverImageLink.trim().isEmpty()) {
+                       URL coverImageUrl = new URL(coverImageLink.trim());
+
+                       // No need to use the oauth, cookies... for the cover
+                       // Plus: it crashes on Android because of the referer
+                       try {
+                               InputStream in = Instance.getInstance().getCache().open(coverImageUrl, null, true);
+                               try {
+                                       meta.setCover(new Image(in));
+                               } finally {
+                                       in.close();
+                               }
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler()
+                                               .error(new IOException("Cannot get the story cover, ignoring...", e));
+                       }
+               }
+
+               return meta;
+       }
+
+       private List<String> getTags() {
+               List<String> tags = new ArrayList<String>();
+               tags.add("MLP");
+
+               int pos = 0;
+               while (pos >= 0) {
+                       pos = indexOfJsonAfter(json, pos, "type", "story_tag");
+                       if (pos >= 0) {
+                               tags.add(getKeyJson(json, pos, "name").trim());
+                       }
+               }
+
+               return tags;
+       }
+
+       @Override
+       protected String getDesc() {
+               String desc = getKeyJson(json, 0, "type", "story", "description");
+               return unbbcode(desc);
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg) {
+               chapterNames = new TreeMap<Integer, String>();
+               chapterContents = new TreeMap<Integer, String>();
+
+               int pos = 0;
+               while (pos >= 0) {
+                       pos = indexOfJsonAfter(json, pos, "type", "chapter");
+                       if (pos >= 0) {
+                               int posNumber = indexOfJsonAfter(json, pos, "chapter_number");
+                               int posComa = json.indexOf(",", posNumber);
+                               final int number = Integer.parseInt(json.substring(posNumber,
+                                               posComa).trim());
+                               final String title = getKeyJson(json, pos, "title");
+                               String notes = getKeyJson(json, pos, "authors_note_html");
+                               String content = getKeyJson(json, pos, "content_html");
+
+                               if (!notes.trim().isEmpty()) {
+                                       notes = "<br/>* * *<br/>" + notes;
+                               }
+
+                               chapterNames.put(number, title);
+                               chapterContents.put(number, content + notes);
+                       }
+               }
+
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+               for (String title : chapterNames.values()) {
+                       urls.add(new AbstractMap.SimpleEntry<String, URL>(title, null));
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, int number, Progress pg) {
+               return chapterContents.get(number);
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "fimfiction.net".equals(url.getHost())
+                               || "www.fimfiction.net".equals(url.getHost());
+       }
+
+       /**
+        * Generate a new token from the client ID and secret.
+        * <p>
+        * Note that those tokens are long-lived, and it would be badly seen to
+        * create a lot of them without due cause.
+        * <p>
+        * So, please cache and re-use them.
+        * 
+        * @param clientId
+        *            the client ID offered on FimFiction
+        * @param clientSecret
+        *            the client secret that goes with it
+        * 
+        * @return a new generated token linked to that client ID
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       static private String generateOAuth(String clientId, String clientSecret)
+                       throws IOException {
+               URL url = new URL("https://www.fimfiction.net/api/v2/token");
+               Map<String, String> params = new HashMap<String, String>();
+               params.put("client_id", clientId);
+               params.put("client_secret", clientSecret);
+               params.put("grant_type", "client_credentials");
+               InputStream in = Instance.getInstance().getCache().openNoCache(url, null, params, null, null);
+
+               String jsonToken = IOUtils.readSmallStream(in);
+               in.close();
+
+               // Extract token type and token from: {
+               // token_type = "Bearer",
+               // access_token = "xxxxxxxxxxxxxx"
+               // }
+
+               String tokenType = getKeyText(jsonToken, "\"token_type\"", "\"", "\"");
+               String token = getKeyText(jsonToken, "\"access_token\"", "\"", "\"");
+
+               return tokenType + " " + token;
+       }
+
+       // afters: [name, value] pairs (or "" for any of them), can end without
+       // value
+       static private int indexOfJsonAfter(String json, int startAt,
+                       String... afterKeys) {
+               ArrayList<String> afters = new ArrayList<String>();
+               boolean name = true;
+               for (String key : afterKeys) {
+                       if (key != null && !key.isEmpty()) {
+                               afters.add("\"" + key + "\"");
+                       } else {
+                               afters.add("\"");
+                               afters.add("\"");
+                       }
+
+                       if (name) {
+                               afters.add(":");
+                       }
+
+                       name = !name;
+               }
+
+               return indexOfAfter(json, startAt, afters.toArray(new String[] {}));
+       }
+
+       // afters: [name, value] pairs (or "" for any of them), can end without
+       // value but will then be empty, not NULL
+       static private String getKeyJson(String json, int startAt,
+                       String... afterKeys) {
+               int pos = indexOfJsonAfter(json, startAt, afterKeys);
+               if (pos < 0) {
+                       return "";
+               }
+
+               String result = "";
+               String wip = json.substring(pos);
+
+               pos = nextUnescapedQuote(wip, 0);
+               if (pos >= 0) {
+                       wip = wip.substring(pos + 1);
+                       pos = nextUnescapedQuote(wip, 0);
+                       if (pos >= 0) {
+                               result = wip.substring(0, pos);
+                       }
+               }
+
+               result = result.replace("\\t", "\t").replace("\\\"", "\"");
+
+               return result;
+       }
+
+       // next " but don't take \" into account
+       static private int nextUnescapedQuote(String result, int pos) {
+               while (pos >= 0) {
+                       pos = result.indexOf("\"", pos);
+                       if (pos == 0 || (pos > 0 && result.charAt(pos - 1) != '\\')) {
+                               break;
+                       }
+
+                       if (pos < result.length()) {
+                               pos++;
+                       }
+               }
+
+               return pos;
+       }
+
+       // quick & dirty filter
+       static private String unbbcode(String bbcode) {
+               String text = bbcode.replace("\\r\\n", "<br/>") //
+                               .replace("[i]", "_").replace("[/i]", "_") //
+                               .replace("[b]", "*").replace("[/b]", "*") //
+                               .replaceAll("\\[[^\\]]*\\]", "");
+               return text;
+       }
+
+       /**
+        * Return the text between the key and the endKey (and optional subKey can
+        * be passed, in this case we will look for the key first, then take the
+        * text between the subKey and the endKey).
+        * 
+        * @param in
+        *            the input
+        * @param key
+        *            the key to match (also supports "^" at start to say
+        *            "only if it starts with" the key)
+        * @param subKey
+        *            the sub key or NULL if none
+        * @param endKey
+        *            the end key or NULL for "up to the end"
+        * @return the text or NULL if not found
+        */
+       static private String getKeyText(String in, String key, String subKey,
+                       String endKey) {
+               String result = null;
+
+               String line = in;
+               if (line != null && line.contains(key)) {
+                       line = line.substring(line.indexOf(key) + key.length());
+                       if (subKey == null || subKey.isEmpty() || line.contains(subKey)) {
+                               if (subKey != null) {
+                                       line = line.substring(line.indexOf(subKey)
+                                                       + subKey.length());
+                               }
+                               if (endKey == null || line.contains(endKey)) {
+                                       if (endKey != null) {
+                                               line = line.substring(0, line.indexOf(endKey));
+                                               result = line;
+                                       }
+                               }
+                       }
+               }
+
+               return result;
+       }
+
+       /**
+        * Return the first index after all the given "afters" have been found in
+        * the {@link String}, or -1 if it was not possible.
+        * 
+        * @param in
+        *            the input
+        * @param startAt
+        *            start at this position in the string
+        * @param afters
+        *            the sub-keys to find before checking for key/endKey
+        * 
+        * @return the text or NULL if not found
+        */
+       static private int indexOfAfter(String in, int startAt, String... afters) {
+               int pos = -1;
+               if (in != null && !in.isEmpty()) {
+                       pos = startAt;
+                       if (afters != null) {
+                               for (int i = 0; pos >= 0 && i < afters.length; i++) {
+                                       String subKey = afters[i];
+                                       if (!subKey.isEmpty()) {
+                                               pos = in.indexOf(subKey, pos);
+                                               if (pos >= 0) {
+                                                       pos += subKey.length();
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return pos;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/Html.java b/src/be/nikiroo/fanfix/supported/Html.java
new file mode 100644 (file)
index 0000000..900fa0a
--- /dev/null
@@ -0,0 +1,89 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.Instance;
+
+/**
+ * Support class for HTML files created with this program (as we need some
+ * metadata available in those we create).
+ * 
+ * @author niki
+ */
+class Html extends InfoText {
+       @Override
+       protected boolean supports(URL url) {
+               try {
+                       File txt = getTxt(url);
+                       if (txt != null) {
+                               return super.supports(txt.toURI().toURL());
+                       }
+               } catch (MalformedURLException e) {
+               }
+
+               return false;
+       }
+
+       @Override
+       protected File getInfoFile() {
+               File source = getSourceFile();
+               if ("index.html".equals(source.getName())) {
+                       source = source.getParentFile();
+               }
+
+               String src = source.getPath();
+               File infoFile = new File(src + ".info");
+               if (!infoFile.exists() && src.endsWith(".txt")) {
+                       infoFile = new File(
+                                       src.substring(0, src.length() - ".txt".length()) + ".info");
+               }
+
+               return infoFile;
+       }
+
+       @Override
+       public URL getCanonicalUrl(URL source) {
+               File txt = getTxt(source);
+               if (txt != null) {
+                       try {
+                               source = txt.toURI().toURL();
+                       } catch (MalformedURLException e) {
+                               Instance.getInstance().getTraceHandler()
+                                               .error(new IOException("Cannot convert the right URL for " + source, e));
+                       }
+               }
+
+               return source;
+       }
+
+       /**
+        * Return the associated TXT source file if it can be found.
+        * 
+        * @param source
+        *            the source URL
+        * 
+        * @return the supported source text file or NULL
+        */
+       private static File getTxt(URL source) {
+               try {
+                       File fakeFile = new File(source.toURI());
+                       if (fakeFile.getName().equals("index.html")) { // "story/index.html"
+                               fakeFile = new File(fakeFile.getParent()); // -> "story/"
+                       }
+
+                       if (fakeFile.isDirectory()) { // "story/"
+                               fakeFile = new File(fakeFile, fakeFile.getName() + ".txt"); // "story/story.txt"
+                       }
+
+                       if (fakeFile.getName().endsWith(".txt")) {
+                               return fakeFile;
+                       }
+               } catch (Exception e) {
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/InfoReader.java b/src/be/nikiroo/fanfix/supported/InfoReader.java
new file mode 100644 (file)
index 0000000..d5eeeb1
--- /dev/null
@@ -0,0 +1,377 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+// not complete: no "description" tag
+public class InfoReader {
+       static protected BasicSupportHelper bsHelper = new BasicSupportHelper();
+       // static protected BasicSupportImages bsImages = new BasicSupportImages();
+       // static protected BasicSupportPara bsPara = new BasicSupportPara(new
+       // BasicSupportHelper(), new BasicSupportImages());
+
+       public static MetaData readMeta(File infoFile, boolean withCover)
+                       throws IOException {
+               if (infoFile == null) {
+                       throw new IOException("File is null");
+               }
+
+               if (infoFile.exists()) {
+                       InputStream in = new MarkableFileInputStream(infoFile);
+                       try {
+                               MetaData meta = createMeta(infoFile.toURI().toURL(), in,
+                                               withCover);
+
+                               // Some old .info files were using UUID for URL...
+                               if (!hasIt(meta.getUrl()) && meta.getUuid() != null
+                                               && (meta.getUuid().startsWith("http://")
+                                                               || meta.getUuid().startsWith("https://"))) {
+                                       meta.setUrl(meta.getUuid());
+                               }
+
+                               // Some old .info files don't have those now required fields...
+                               // So we check if we can find the info in another way (many
+                               // formats have a copy of the original text file)
+                               if (!hasIt(meta.getTitle(), meta.getAuthor(), meta.getDate(),
+                                               meta.getUrl())) {
+
+                                       // TODO: not nice, would be better to do it properly...
+                                       String base = infoFile.getPath();
+                                       if (base.endsWith(".info")) {
+                                               base = base.substring(0,
+                                                               base.length() - ".info".length());
+                                       }
+                                       File textFile = new File(base);
+                                       if (!textFile.exists()) {
+                                               textFile = new File(base + ".txt");
+                                       }
+                                       if (!textFile.exists()) {
+                                               textFile = new File(base + ".text");
+                                       }
+
+                                       completeMeta(textFile, meta);
+                                       //
+                               }
+
+                               return meta;
+                       } finally {
+                               in.close();
+                       }
+               }
+
+               throw new FileNotFoundException(
+                               "File given as argument does not exists: "
+                                               + infoFile.getAbsolutePath());
+       }
+       
+       /**
+        * Complete the given {@link MetaData} with the original text file if needed
+        * and possible.
+        * 
+        * @param textFile
+        *            the original text file
+        * @param meta
+        *            the {@link MetaData} to complete if needed and possible
+        * 
+        * @throws IOException
+        *             in case of I/O errors
+        */
+       static public void completeMeta(File textFile,
+                       MetaData meta)  throws IOException {
+               if (textFile != null && textFile.exists()) {
+                       final URL source = textFile.toURI().toURL();
+                       final MetaData[] superMetaA = new MetaData[1];
+                       @SuppressWarnings("unused")
+                       Text unused = new Text() {
+                               private boolean loaded = loadDocument();
+
+                               @Override
+                               public SupportType getType() {
+                                       return SupportType.TEXT;
+                               }
+
+                               protected boolean loadDocument() throws IOException {
+                                       loadDocument(source);
+                                       superMetaA[0] = getMeta();
+                                       return true;
+                               }
+
+                               @Override
+                               protected Image getCover(File sourceFile) {
+                                       return null;
+                               }
+                       };
+
+                       MetaData superMeta = superMetaA[0];
+                       if (!hasIt(meta.getTitle())) {
+                               meta.setTitle(superMeta.getTitle());
+                       }
+                       if (!hasIt(meta.getAuthor())) {
+                               meta.setAuthor(superMeta.getAuthor());
+                       }
+                       if (!hasIt(meta.getDate())) {
+                               meta.setDate(superMeta.getDate());
+                       }
+                       if (!hasIt(meta.getUrl())) {
+                               meta.setUrl(superMeta.getUrl());
+                       }
+               }
+       }
+
+       /**
+        * Check if we have non-empty values for all the given {@link String}s.
+        * 
+        * @param values
+        *            the values to check
+        * 
+        * @return TRUE if none of them was NULL or empty
+        */
+       static private boolean hasIt(String... values) {
+               for (String value : values) {
+                       if (value == null || value.trim().isEmpty()) {
+                               return false;
+                       }
+               }
+
+               return true;
+       }
+
+       private static MetaData createMeta(URL sourceInfoFile, InputStream in,
+                       boolean withCover) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getInfoTag(in, "TITLE"));
+               meta.setAuthor(getInfoTag(in, "AUTHOR"));
+               meta.setDate(bsHelper.formatDate(getInfoTag(in, "DATE")));
+               meta.setTags(getInfoTagList(in, "TAGS", ","));
+               meta.setSource(getInfoTag(in, "SOURCE"));
+               meta.setUrl(getInfoTag(in, "URL"));
+               meta.setPublisher(getInfoTag(in, "PUBLISHER"));
+               meta.setUuid(getInfoTag(in, "UUID"));
+               meta.setLuid(getInfoTag(in, "LUID"));
+               meta.setLang(getInfoTag(in, "LANG"));
+               meta.setSubject(getInfoTag(in, "SUBJECT"));
+               meta.setType(getInfoTag(in, "TYPE"));
+               meta.setImageDocument(getInfoTagBoolean(in, "IMAGES_DOCUMENT", false));
+               if (withCover) {
+                       String infoTag = getInfoTag(in, "COVER");
+                       if (infoTag != null && !infoTag.trim().isEmpty()) {
+                               meta.setCover(bsHelper.getImage(null, sourceInfoFile, infoTag));
+                       }
+                       if (meta.getCover() == null) {
+                               // Second chance: try to check for a cover next to the info file
+                               meta.setCover(getCoverByName(sourceInfoFile));
+                       }
+               }
+               try {
+                       meta.setWords(Long.parseLong(getInfoTag(in, "WORDCOUNT")));
+               } catch (NumberFormatException e) {
+                       meta.setWords(0);
+               }
+               meta.setCreationDate(
+                               bsHelper.formatDate(getInfoTag(in, "CREATION_DATE")));
+               meta.setFakeCover(Boolean.parseBoolean(getInfoTag(in, "FAKE_COVER")));
+
+               if (withCover && meta.getCover() == null) {
+                       meta.setCover(bsHelper.getDefaultCover(meta.getSubject()));
+               }
+
+               return meta;
+       }
+
+       /**
+        * Return the cover image if it is next to the source file.
+        * 
+        * @param sourceInfoFile
+        *            the source file
+        * 
+        * @return the cover if present, NULL if not
+        */
+       public static Image getCoverByName(URL sourceInfoFile) {
+               Image cover = null;
+
+               File basefile = new File(sourceInfoFile.getFile());
+
+               String ext = "." + Instance.getInstance().getConfig()
+                               .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
+
+               // Without removing ext
+               cover = bsHelper.getImage(null, sourceInfoFile,
+                               basefile.getAbsolutePath() + ext);
+
+               // Try without ext
+               String name = basefile.getName();
+               int pos = name.lastIndexOf(".");
+               if (cover == null && pos > 0) {
+                       name = name.substring(0, pos);
+                       basefile = new File(basefile.getParent(), name);
+
+                       cover = bsHelper.getImage(null, sourceInfoFile,
+                                       basefile.getAbsolutePath() + ext);
+               }
+
+               return cover;
+       }
+
+       private static boolean getInfoTagBoolean(InputStream in, String key,
+                       boolean def) throws IOException {
+               Boolean value = getInfoTagBoolean(in, key);
+               return value == null ? def : value;
+       }
+
+       private static Boolean getInfoTagBoolean(InputStream in, String key)
+                       throws IOException {
+               String value = getInfoTag(in, key);
+               if (value != null && !value.trim().isEmpty()) {
+                       value = value.toLowerCase().trim();
+                       return value.equals("1") || value.equals("on")
+                                       || value.equals("true") || value.equals("yes");
+               }
+
+               return null;
+       }
+
+       private static List<String> getInfoTagList(InputStream in, String key,
+                       String separator) throws IOException {
+               List<String> list = new ArrayList<String>();
+               String tt = getInfoTag(in, key);
+               if (tt != null) {
+                       for (String tag : tt.split(separator)) {
+                               list.add(tag.trim());
+                       }
+               }
+
+               return list;
+       }
+
+       /**
+        * Return the value of the given tag in the <tt>.info</tt> file if present.
+        * 
+        * @param key
+        *            the tag key
+        * 
+        * @return the value or NULL
+        * 
+        * @throws IOException
+        *             in case of I/O error
+        */
+       private static String getInfoTag(InputStream in, String key)
+                       throws IOException {
+               key = "^" + key + "=";
+
+               if (in != null) {
+                       in.reset();
+                       String value = getLine(in, key, 0);
+                       if (value != null && !value.isEmpty()) {
+                               value = value.trim().substring(key.length() - 1).trim();
+                               if (value.length() > 1 && //
+                                               (value.startsWith("'") && value.endsWith("'")
+                                                               || value.startsWith("\"")
+                                                                               && value.endsWith("\""))) {
+                                       value = value.substring(1, value.length() - 1).trim();
+                               }
+
+                               // Some old files ended up with TITLE="'xxxxx'"
+                               if ("^TITLE=".equals(key)) {
+                                       if (value.startsWith("'") && value.endsWith("'")
+                                                       && value.length() > 1) {
+                                               value = value.substring(1, value.length() - 1).trim();
+                                       }
+                               }
+
+                               return value;
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * Return the first line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * 
+        * @return the line
+        */
+       static private String getLine(InputStream in, String needle,
+                       int relativeLine) {
+               return getLine(in, needle, relativeLine, true);
+       }
+
+       /**
+        * Return a line from the given input which correspond to the given
+        * selectors.
+        * 
+        * @param in
+        *            the input
+        * @param needle
+        *            a string that must be found inside the target line (also
+        *            supports "^" at start to say "only if it starts with" the
+        *            needle)
+        * @param relativeLine
+        *            the line to return based upon the target line position (-1 =
+        *            the line before, 0 = the target line...)
+        * @param first
+        *            takes the first result (as opposed to the last one, which will
+        *            also always spend the input)
+        * 
+        * @return the line
+        */
+       static private String getLine(InputStream in, String needle,
+                       int relativeLine, boolean first) {
+               String rep = null;
+
+               List<String> lines = new ArrayList<String>();
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               int index = -1;
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       lines.add(scan.next());
+
+                       if (index == -1) {
+                               if (needle.startsWith("^")) {
+                                       if (lines.get(lines.size() - 1)
+                                                       .startsWith(needle.substring(1))) {
+                                               index = lines.size() - 1;
+                                       }
+
+                               } else {
+                                       if (lines.get(lines.size() - 1).contains(needle)) {
+                                               index = lines.size() - 1;
+                                       }
+                               }
+                       }
+
+                       if (index >= 0 && index + relativeLine < lines.size()) {
+                               rep = lines.get(index + relativeLine);
+                               if (first) {
+                                       break;
+                               }
+                       }
+               }
+
+               return rep;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/InfoText.java b/src/be/nikiroo/fanfix/supported/InfoText.java
new file mode 100644 (file)
index 0000000..2af8c7e
--- /dev/null
@@ -0,0 +1,32 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+
+import be.nikiroo.fanfix.data.MetaData;
+
+/**
+ * Support class for <tt>.info</tt> text files ({@link Text} files with a
+ * <tt>.info</tt> metadata file next to them).
+ * <p>
+ * The <tt>.info</tt> file is supposed to be written by this program, or
+ * compatible.
+ * 
+ * @author niki
+ */
+class InfoText extends Text {
+       protected File getInfoFile() {
+               return new File(assureNoTxt(getSourceFile()).getPath() + ".info");
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               return InfoReader.readMeta(getInfoFile(), true);
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return supports(url, true);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/MangaHub.java b/src/be/nikiroo/fanfix/supported/MangaHub.java
new file mode 100644 (file)
index 0000000..706a591
--- /dev/null
@@ -0,0 +1,214 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.jsoup.nodes.Element;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="https://mangahub.io/">MangaHub</a>, a website
+ * dedicated to Manga.
+ * 
+ * @author niki
+ */
+class MangaHub extends BasicSupport {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle());
+               meta.setDate("");
+               meta.setAuthor(getAuthor());
+               meta.setTags(getTags());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("manga");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover());
+
+               return meta;
+       }
+
+       private String getTitle() {
+               Element doc = getSourceNode();
+
+               Element el = doc.getElementsByTag("h1").first();
+               if (el != null) {
+                       return StringUtils.unhtml(el.text()).trim();
+               }
+
+               return null;
+       }
+
+       private String getAuthor() {
+               String author = "";
+
+               Element el = getSourceNode().select("h1+div span:not([class])").first();
+               if (el != null)
+                       author = StringUtils.unhtml(el.text()).trim();
+               return author;
+       }
+
+       private List<String> getTags() {
+               return getListA("genre-label");
+       }
+
+       private List<String> getListA(String uniqueClass) {
+               List<String> list = new ArrayList<String>();
+
+               Element doc = getSourceNode();
+               Element el = doc.getElementsByClass(uniqueClass).first();
+               if (el != null) {
+                       for (Element valueA : el.getElementsByTag("a")) {
+                               list.add(StringUtils.unhtml(valueA.text()).trim());
+                       }
+               }
+
+               return list;
+       }
+
+       @Override
+       protected String getDesc() {
+               Element doc = getSourceNode();
+               Element title = doc.getElementsByClass("fullcontent").first();
+               if (title != null) {
+                       return StringUtils.unhtml(title.text()).trim();
+               }
+
+               return null;
+       }
+
+       private Image getCover() {
+               Element doc = getSourceNode();
+               Element cover = doc.getElementsByClass("manga-thumb").first();
+               if (cover != null) {
+                       try {
+                               return bsImages.getImage(this, new URL(cover.absUrl("src")));
+                       } catch (MalformedURLException e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+               }
+
+               return null;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg) {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               Element doc = getSourceNode();
+               for (Element el : doc.getElementsByClass("list-group-item")) {
+                       Element urlEl = el.getElementsByTag("a").first();
+                       if (urlEl == null)
+                               continue;
+
+                       String url = urlEl.absUrl("href");
+
+                       String title = "";
+                       el = el.getElementsByClass("text-secondary").first();
+                       if (el != null) {
+                               title = StringUtils.unhtml(el.text()).trim();
+                       }
+
+                       try {
+                               urls.add(new AbstractMap.SimpleEntry<String, URL>(title, new URL(url)));
+                       } catch (Exception e) {
+                               Instance.getInstance().getTraceHandler().error(e);
+                       }
+               }
+
+               // by default, the chapters are in reversed order
+               Collections.reverse(urls);
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg) throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               // 1. Get the title and chapter url part
+               String path = chapUrl.getPath();
+               if (path.endsWith("/")) {
+                       path = path.substring(0, path.length() - "/".length());
+               }
+               String tab[] = path.split("/");
+               String chap = tab[tab.length - 1];
+               String title = tab[tab.length - 2];
+
+               if (chap.startsWith("chapter-")) {
+                       chap = chap.substring("chapter-".length());
+               }
+
+               // 2. generate an image base
+               String base = "https://img.mghubcdn.com/file/imghub/" + title + "/" + chap + "/";
+
+               // 3. add each chapter
+               StringBuilder builder = new StringBuilder();
+
+               int i = 1;
+               String url = base + i + ".jpg";
+               while (getHttpStatus(new URL(url)) != 404) {
+                       builder.append("[");
+                       builder.append(url);
+                       builder.append("]<br/>");
+
+                       i++;
+                       url = base + i + ".jpg";
+               }
+
+               return builder.toString();
+       }
+
+       // HTTP response code, or -1 if other error
+       // TODO: move that to Downloader?
+       private int getHttpStatus(URL url) {
+               try {
+                       HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+                       try {
+                               conn.setRequestMethod("HEAD");
+                               conn.setRequestProperty("User-Agent", Instance.getInstance().getConfig().getString(Config.NETWORK_USER_AGENT));
+                               conn.setRequestProperty("Accept-Encoding", "gzip");
+                               conn.setRequestProperty("Accept", "*/*");
+                               conn.setRequestProperty("Charset", "utf-8");
+
+                               return conn.getResponseCode();
+                       } finally {
+                               conn.disconnect();
+                       }
+               } catch (Exception e) {
+                       return -1;
+               }
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return "mangahub.io".equals(url.getHost()) || "www.mangahub.io".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/MangaLel.java b/src/be/nikiroo/fanfix/supported/MangaLel.java
new file mode 100644 (file)
index 0000000..de0b871
--- /dev/null
@@ -0,0 +1,231 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.jsoup.helper.DataUtil;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+class MangaLel extends BasicSupport {
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle());
+               meta.setAuthor(getAuthor());
+               meta.setDate(bsHelper.formatDate(getDate()));
+               meta.setTags(getTags());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSource().toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(getSource().toString());
+               meta.setLuid("");
+               meta.setLang("fr");
+               meta.setSubject("manga");
+               meta.setType(getType().toString());
+               meta.setImageDocument(true);
+               meta.setCover(getCover());
+
+               return meta;
+       }
+
+       private String getTitle() {
+               Element doc = getSourceNode();
+               Element h4 = doc.getElementsByTag("h4").first();
+               if (h4 != null) {
+                       return StringUtils.unhtml(h4.text()).trim();
+               }
+
+               return null;
+       }
+
+       private String getAuthor() {
+               Element doc = getSourceNode();
+               Element tabEls = doc.getElementsByClass("presentation-projet").first();
+               if (tabEls != null) {
+                       String[] tab = tabEls.outerHtml().split("<br>");
+                       return getVal(tab, 1);
+               }
+
+               return "";
+       }
+
+       private List<String> getTags() {
+               Element doc = getSourceNode();
+               Element tabEls = doc.getElementsByClass("presentation-projet").first();
+               if (tabEls != null) {
+                       String[] tab = tabEls.outerHtml().split("<br>");
+                       List<String> tags = new ArrayList<String>();
+                       for (String tag : getVal(tab, 3).split(" ")) {
+                               tags.add(tag);
+                       }
+                       return tags;
+               }
+
+               return new ArrayList<String>();
+
+       }
+
+       private String getDate() {
+               Element doc = getSourceNode();
+               Element table = doc.getElementsByClass("table").first();
+
+               // We take the first date we find
+               String value = "";
+               if (table != null) {
+                       Elements els;
+                       els = table.getElementsByTag("tr");
+                       if (els.size() >= 2) {
+                               els = els.get(1).getElementsByTag("td");
+                               if (els.size() >= 3) {
+                                       value = StringUtils.unhtml(els.get(2).text()).trim();
+                               }
+                       }
+               }
+
+               return value;
+       }
+
+       @Override
+       protected String getDesc() {
+               Element doc = getSourceNode();
+               Element tabEls = doc.getElementsByClass("presentation-projet").first();
+               if (tabEls != null) {
+                       String[] tab = tabEls.outerHtml().split("<br>");
+                       return getVal(tab, 4);
+               }
+
+               return "";
+       }
+
+       private Image getCover() {
+               Element doc = getSourceNode();
+               Element container = doc.getElementsByClass("container").first();
+
+               if (container != null) {
+
+                       Elements imgs = container.getElementsByTag("img");
+                       Element img = null;
+                       if (imgs.size() >= 1) {
+                               img = imgs.get(0);
+                               if (img.hasClass("banniere-team-projet")) {
+                                       img = null;
+                                       if (imgs.size() >= 2) {
+                                               img = imgs.get(1);
+                                       }
+                               }
+                       }
+
+                       if (img != null) {
+                               String coverUrl = img.absUrl("src");
+
+                               InputStream coverIn;
+                               try {
+                                       coverIn = Instance.getInstance().getCache().open(new URL(coverUrl), this, true);
+                                       try {
+                                               return new Image(coverIn);
+                                       } finally {
+                                               coverIn.close();
+                                       }
+                               } catch (IOException e) {
+                                       Instance.getInstance().getTraceHandler().error(e);
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getVal(String[] tab, int i) {
+               String val = "";
+
+               if (i < tab.length) {
+                       val = StringUtils.unhtml(tab[i]);
+                       int pos = val.indexOf(":");
+                       if (pos >= 0) {
+                               val = val.substring(pos + 1).trim();
+                       }
+               }
+
+               return val;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               Element doc = getSourceNode();
+               Element table = doc.getElementsByClass("table").first();
+               if (table != null) {
+                       for (Element tr : table.getElementsByTag("tr")) {
+                               Element a = tr.getElementsByTag("a").first();
+                               if (a != null) {
+                                       String name = StringUtils.unhtml(a.text()).trim();
+                                       URL url = new URL(a.absUrl("href"));
+                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(name, url));
+                               }
+                       }
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL chapUrl, int number, Progress pg)
+                       throws IOException {
+               if (pg == null) {
+                       pg = new Progress();
+               }
+
+               StringBuilder builder = new StringBuilder();
+
+               InputStream in = Instance.getInstance().getCache().open(chapUrl, this, false);
+               try {
+                       Element pageDoc = DataUtil.load(in, "UTF-8", chapUrl.toString());
+                       Element content = pageDoc.getElementById("content");
+                       Elements linkEls = content.getElementsByTag("img");
+                       for (Element linkEl : linkEls) {
+                               if (linkEl.absUrl("src").isEmpty()) {
+                                       continue;
+                               }
+
+                               builder.append("[");
+                               builder.append(linkEl.absUrl("src"));
+                               builder.append("]<br/>");
+                       }
+
+               } finally {
+                       in.close();
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               // URL structure (the projectId is the manga key):
+               // http://mangas-lecture-en-ligne.fr/index_lel.php?page=presentationProjet&idProjet=999
+
+               return "mangas-lecture-en-ligne.fr".equals(url.getHost());
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/SupportType.java b/src/be/nikiroo/fanfix/supported/SupportType.java
new file mode 100644 (file)
index 0000000..7ace726
--- /dev/null
@@ -0,0 +1,140 @@
+package be.nikiroo.fanfix.supported;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+
+/**
+ * The supported input types for which we can get a {@link BasicSupport} object.
+ * 
+ * @author niki
+ */
+public enum SupportType {
+       /** EPUB files created with this program */
+       EPUB,
+       /** Pure text file with some rules */
+       TEXT,
+       /** TEXT but with associated .info file */
+       INFO_TEXT,
+       /** My Little Pony fanfictions */
+       FIMFICTION,
+       /** Fanfictions from a lot of different universes */
+       FANFICTION,
+       /** Website with lots of Mangas */
+       MANGAHUB,
+       /** Furry website with comics support */
+       E621,
+       /** Furry website with stories */
+       YIFFSTAR,
+       /** Comics and images groups, mostly but not only NSFW */
+       E_HENTAI,
+       /** Website with lots of Mangas, in French */
+       MANGA_LEL,
+       /** CBZ files */
+       CBZ,
+       /** HTML files */
+       HTML;
+
+       /**
+        * The name of this support type (a short version).
+        * 
+        * @return the name
+        */
+       public String getSourceName() {
+               switch (this) {
+               case CBZ:
+                       return "cbz";
+               case E621:
+                       return "e621.net";
+               case E_HENTAI:
+                       return "e-hentai.org";
+               case EPUB:
+                       return "epub";
+               case FANFICTION:
+                       return "Fanfiction.net";
+               case FIMFICTION:
+                       return "FimFiction.net";
+               case HTML:
+                       return "html";
+               case INFO_TEXT:
+                       return "info-text";
+               case MANGA_LEL:
+                       return "MangaLEL";
+               case MANGAHUB:
+                       return "MangaHub.io";
+               case TEXT:
+                       return "text";
+               case YIFFSTAR:
+                       return "YiffStar";
+               }
+
+               return "";
+       }
+
+       /**
+        * A description of this support type (more information than
+        * {@link SupportType#getSourceName()}).
+        * 
+        * @return the description
+        */
+       public String getDesc() {
+               String desc = Instance.getInstance().getTrans().getStringX(StringId.INPUT_DESC, this.name());
+
+               if (desc == null) {
+                       desc = Instance.getInstance().getTrans().getString(StringId.INPUT_DESC, this);
+               }
+
+               return desc;
+       }
+
+       @Override
+       public String toString() {
+               return super.toString().toLowerCase();
+       }
+
+       /**
+        * Call {@link SupportType#valueOf(String)} after conversion to upper case.
+        * 
+        * @param typeName
+        *            the possible type name
+        * 
+        * @return NULL or the type
+        */
+       public static SupportType valueOfUC(String typeName) {
+               return SupportType.valueOf(typeName == null ? null : typeName
+                               .toUpperCase());
+       }
+
+       /**
+        * Call {@link SupportType#valueOf(String)} after conversion to upper case
+        * but return NULL for NULL instead of raising exception.
+        * 
+        * @param typeName
+        *            the possible type name
+        * 
+        * @return NULL or the type
+        */
+       public static SupportType valueOfNullOkUC(String typeName) {
+               if (typeName == null) {
+                       return null;
+               }
+
+               return SupportType.valueOfUC(typeName);
+       }
+
+       /**
+        * Call {@link SupportType#valueOf(String)} after conversion to upper case
+        * but return NULL in case of error instead of raising an exception.
+        * 
+        * @param typeName
+        *            the possible type name
+        * 
+        * @return NULL or the type
+        */
+       public static SupportType valueOfAllOkUC(String typeName) {
+               try {
+                       return SupportType.valueOfUC(typeName);
+               } catch (Exception e) {
+                       return null;
+               }
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/supported/Text.java b/src/be/nikiroo/fanfix/supported/Text.java
new file mode 100644 (file)
index 0000000..232eab6
--- /dev/null
@@ -0,0 +1,390 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import org.jsoup.nodes.Document;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.ImageUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.streams.MarkableFileInputStream;
+
+/**
+ * Support class for local stories encoded in textual format, with a few rules:
+ * <ul>
+ * <li>The title must be on the first line</li>
+ * <li>The author (preceded by nothing, "by " or "©") must be on the second
+ * line, possibly with the publication date in parenthesis (i.e., "
+ * <tt>By Unknown (3rd October 1998)</tt>")</li>
+ * <li>Chapters must be declared with "<tt>Chapter x</tt>" or "
+ * <tt>Chapter x: NAME OF THE CHAPTER</tt>", where "<tt>x</tt>" is the chapter
+ * number</li>
+ * <li>A description of the story must be given as chapter number 0</li>
+ * <li>A cover may be present, with the same filename but a PNG, JPEG or JPG
+ * extension</li>
+ * </ul>
+ * 
+ * @author niki
+ */
+class Text extends BasicSupport {
+       private File sourceFile;
+       private InputStream in;
+
+       protected File getSourceFile() {
+               return sourceFile;
+       }
+
+       protected InputStream getInput() {
+               if (in != null) {
+                       try {
+                               in.reset();
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(new IOException("Cannot reset the Text stream", e));
+                       }
+
+                       return in;
+               }
+
+               return null;
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return false;
+       }
+
+       @Override
+       protected Document loadDocument(URL source) throws IOException {
+               try {
+                       sourceFile = new File(source.toURI());
+                       in = new MarkableFileInputStream(sourceFile);
+               } catch (URISyntaxException e) {
+                       throw new IOException("Cannot load the text document: " + source);
+               }
+
+               return null;
+       }
+
+       @Override
+       protected MetaData getMeta() throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle());
+               meta.setAuthor(getAuthor());
+               meta.setDate(bsHelper.formatDate(getDate()));
+               meta.setTags(new ArrayList<String>());
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(getSourceFile().toURI().toURL().toString());
+               meta.setPublisher("");
+               meta.setUuid(getSourceFile().toString());
+               meta.setLuid("");
+               meta.setLang(getLang()); // default is EN
+               meta.setSubject(getSourceFile().getParentFile().getName());
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(getSourceFile()));
+               
+               return meta;
+       }
+
+       private String getLang() {
+               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next(); // Title
+               scan.next(); // Author (Date)
+               String chapter0 = scan.next(); // empty or Chapter 0
+               while (chapter0.isEmpty()) {
+                       chapter0 = scan.next();
+               }
+
+               String lang = detectChapter(chapter0, 0);
+               if (lang == null) {
+                       // No description??
+                       lang = detectChapter(chapter0, 1);
+               }
+
+               if (lang == null) {
+                       lang = "en";
+               } else {
+                       lang = lang.toLowerCase();
+               }
+
+               return lang;
+       }
+
+       private String getTitle() {
+               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               return scan.next();
+       }
+
+       private String getAuthor() {
+               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next();
+               String authorDate = scan.next();
+
+               String author = authorDate;
+               int pos = authorDate.indexOf('(');
+               if (pos >= 0) {
+                       author = authorDate.substring(0, pos);
+               }
+
+               return bsHelper.fixAuthor(author);
+       }
+
+       private String getDate() {
+               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next();
+               String authorDate = scan.next();
+
+               String date = "";
+               int pos = authorDate.indexOf('(');
+               if (pos >= 0) {
+                       date = authorDate.substring(pos + 1).trim();
+                       pos = date.lastIndexOf(')');
+                       if (pos >= 0) {
+                               date = date.substring(0, pos).trim();
+                       }
+               }
+
+               return date;
+       }
+
+       @Override
+       protected String getDesc() throws IOException {
+               String content = getChapterContent(null, 0, null).trim();
+               if (!content.isEmpty()) {
+                       Chapter desc = bsPara.makeChapter(this, null, 0, "Description",
+                                       content, isHtml(), null);
+                       StringBuilder builder = new StringBuilder();
+                       for (Paragraph para : desc) {
+                               if (builder.length() > 0) {
+                                       builder.append("\n");
+                               }
+                               builder.append(para.getContent());
+                       }
+               }
+
+               return content;
+       }
+
+       protected Image getCover(File sourceFile) {
+               String path = sourceFile.getName();
+
+               for (String ext : new String[] { ".txt", ".text", ".story" }) {
+                       if (path.endsWith(ext)) {
+                               path = path.substring(0, path.length() - ext.length());
+                       }
+               }
+
+               Image cover = bsImages.getImage(this, sourceFile.getParentFile(), path);
+               if (cover != null) {
+                       try {
+                               File tmp = Instance.getInstance().getTempFiles().createTempFile("test_cover_image");
+                               ImageUtils.getInstance().saveAsImage(cover, tmp, "png");
+                               tmp.delete();
+                       } catch (IOException e) {
+                               cover = null;
+                       }
+               }
+
+               return cover;
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(Progress pg)
+                       throws IOException {
+               List<Entry<String, URL>> chaps = new ArrayList<Entry<String, URL>>();
+               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               String line = "first is not empty";
+               while (scan.hasNext()) {
+                       boolean prevLineEmpty = line.trim().isEmpty();
+                       line = scan.next();
+                       if (prevLineEmpty && detectChapter(line, chaps.size() + 1) != null) {
+                               String chapName = Integer.toString(chaps.size() + 1);
+                               int pos = line.indexOf(':');
+                               if (pos >= 0 && pos + 1 < line.length()) {
+                                       chapName = line.substring(pos + 1).trim();
+                               }
+
+                               chaps.add(new AbstractMap.SimpleEntry<String, URL>(//
+                                               chapName, //
+                                               getSourceFile().toURI().toURL()));
+                       }
+               }
+               
+               return chaps;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, int number, Progress pg)
+                       throws IOException {
+               StringBuilder builder = new StringBuilder();
+               @SuppressWarnings("resource") // cannot close, or we loose getInput()!
+               Scanner scan = new Scanner(getInput(), "UTF-8");
+               scan.useDelimiter("\\n");
+               scan.next(); // title
+               scan.next(); // author
+               scan.next(); // date or empty
+               Boolean inChap = null;
+               String line = "";
+               while (scan.hasNext()) {
+                       if (number == 0 && !line.trim().isEmpty()) {
+                               // We found pre-chapter content, we are checking for
+                               // Chapter 0 (fake chapter) --> keep the content
+                               if (inChap == null)
+                                       inChap = true;
+                       }
+                       line = scan.next();
+                       if ((inChap == null || !inChap) && detectChapter(line, number) != null) {
+                               inChap = true;
+                       } else if (detectChapter(line, number + 1) != null) {
+                               break;
+                       } else if (inChap != null && inChap) {
+                               builder.append(line);
+                               builder.append("\n");
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       @Override
+       protected void close() {
+               InputStream in = getInput();
+               if (in != null) {
+                       try {
+                               in.close();
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler()
+                                               .error(new IOException("Cannot close the text source file input", e));
+                       }
+               }
+
+               super.close();
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               return supports(url, false);
+       }
+
+       /**
+        * Check if we supports this {@link URL}, that is, if the info file can be
+        * found OR not found.
+        * <p>
+        * It must also be a file, not another kind of URL.
+        * 
+        * @param url
+        *            the {@link URL} to check
+        * @param info
+        *            TRUE to require the info file, FALSE to forbid the info file
+        * 
+        * @return TRUE if it is supported
+        */
+       protected boolean supports(URL url, boolean info) {
+               if (!"file".equals(url.getProtocol())) {
+                       return false;
+               }
+
+               boolean infoPresent = false;
+               File file;
+               try {
+                       file = new File(url.toURI());
+                       file = assureNoTxt(file);
+                       file = new File(file.getPath() + ".info");
+               } catch (URISyntaxException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+                       file = null;
+               }
+
+               infoPresent = (file != null && file.exists());
+
+               return infoPresent == info;
+       }
+
+       /**
+        * Remove the ".txt" (or ".text") extension if it is present.
+        * 
+        * @param file
+        *            the file to process
+        * 
+        * @return the same file or a copy of it without the ".txt" extension if it
+        *         was present
+        */
+       protected File assureNoTxt(File file) {
+               for (String ext : new String[] { ".txt", ".text" }) {
+                       if (file.getName().endsWith(ext)) {
+                               file = new File(file.getPath().substring(0,
+                                               file.getPath().length() - ext.length()));
+                       }
+               }
+
+               return file;
+       }
+
+       /**
+        * Check if the given line looks like the given starting chapter in a
+        * supported language, and return the language if it does (or NULL if not).
+        * 
+        * @param line
+        *            the line to check
+        * @param number
+        *            the specific chapter number to check for
+        * 
+        * @return the language or NULL
+        */
+       static private String detectChapter(String line, int number) {
+               line = line.toUpperCase();
+               for (String lang : Instance.getInstance().getConfig().getList(Config.CONF_CHAPTER)) {
+                       String chapter = Instance.getInstance().getConfig().getStringX(Config.CONF_CHAPTER, lang);
+                       if (chapter != null && !chapter.isEmpty()) {
+                               chapter = chapter.toUpperCase() + " ";
+                               if (line.startsWith(chapter)) {
+                                       // We want "[CHAPTER] [number]: [name]", with ": [name]"
+                                       // optional
+                                       String test = line.substring(chapter.length()).trim();
+
+                                       String possibleNum = test.trim();
+                                       if (possibleNum.indexOf(':') > 0) {
+                                               possibleNum = possibleNum.substring(0,
+                                                               possibleNum.indexOf(':')).trim();
+                                       }
+
+                                       if (test.startsWith(Integer.toString(number))) {
+                                               test = test
+                                                               .substring(Integer.toString(number).length())
+                                                               .trim();
+                                               if (test.isEmpty() || test.startsWith(":")) {
+                                                       return lang;
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/YiffStar.java b/src/be/nikiroo/fanfix/supported/YiffStar.java
new file mode 100644 (file)
index 0000000..6974e9a
--- /dev/null
@@ -0,0 +1,267 @@
+package be.nikiroo.fanfix.supported;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Scanner;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.StringUtils;
+
+/**
+ * Support class for <a href="https://sofurry.com/">SoFurry.com</a>, a Furry
+ * website supporting images and stories (we only retrieve the stories).
+ * 
+ * @author niki
+ */
+class YiffStar extends BasicSupport_Deprecated {
+       @Override
+       protected MetaData getMeta(URL source, InputStream in) throws IOException {
+               MetaData meta = new MetaData();
+
+               meta.setTitle(getTitle(reset(in)));
+               meta.setAuthor(getAuthor(reset(in)));
+               meta.setDate("");
+               meta.setTags(getTags(reset(in)));
+               meta.setSource(getType().getSourceName());
+               meta.setUrl(source.toString());
+               meta.setPublisher(getType().getSourceName());
+               meta.setUuid(source.toString());
+               meta.setLuid("");
+               meta.setLang("en");
+               meta.setSubject("Furry");
+               meta.setType(getType().toString());
+               meta.setImageDocument(false);
+               meta.setCover(getCover(source, reset(in)));
+
+               return meta;
+       }
+
+       @Override
+       protected boolean supports(URL url) {
+               String host = url.getHost();
+               if (host.startsWith("www.")) {
+                       host = host.substring("www.".length());
+               }
+
+               return "sofurry.com".equals(host);
+       }
+
+       @Override
+       protected boolean isHtml() {
+               return true;
+       }
+
+       @Override
+       public void login() throws IOException {
+               // Note: this should not be necessary anymore
+               // (the "/guest" trick is enough)
+               String login = Instance.getInstance().getConfig().getString(Config.LOGIN_YIFFSTAR_USER);
+               String password = Instance.getInstance().getConfig().getString(Config.LOGIN_YIFFSTAR_PASS);
+
+               if (login != null && !login.isEmpty() && password != null
+                               && !password.isEmpty()) {
+
+                       Map<String, String> post = new HashMap<String, String>();
+                       post.put("LoginForm[sfLoginUsername]", login);
+                       post.put("LoginForm[sfLoginPassword]", password);
+                       post.put("YII_CSRF_TOKEN", "");
+                       post.put("yt1", "Login");
+                       post.put("returnUrl", "/");
+
+                       // Cookies will actually be retained by the cache manager once
+                       // logged in
+                       Instance.getInstance().getCache()
+                                       .openNoCache(new URL("https://www.sofurry.com/user/login"), this, post, null, null).close();
+               }
+       }
+
+       @Override
+       public URL getCanonicalUrl(URL source) {
+               try {
+                       if (source.getPath().startsWith("/view")) {
+                               source = guest(source.toString());
+                               // NO CACHE because we don't want the NotLoggedIn message later
+                               InputStream in = Instance.getInstance().getCache().openNoCache(source, this, null, null, null);
+                               String line = getLine(in, "/browse/folder/", 0);
+                               if (line != null) {
+                                       String[] tab = line.split("\"");
+                                       if (tab.length > 1) {
+                                               String groupUrl = source.getProtocol() + "://"
+                                                               + source.getHost() + tab[1];
+                                               return guest(groupUrl);
+                                       }
+                               }
+                       }
+               } catch (Exception e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+
+               return super.getCanonicalUrl(source);
+       }
+
+       private List<String> getTags(InputStream in) {
+               List<String> tags = new ArrayList<String>();
+
+               String line = getLine(in, "class=\"sf-story-big-tags", 0);
+               if (line != null) {
+                       String[] tab = StringUtils.unhtml(line).split(",");
+                       for (String possibleTag : tab) {
+                               String tag = possibleTag.trim();
+                               if (!tag.isEmpty() && !tag.equals("...") && !tags.contains(tag)) {
+                                       tags.add(tag);
+                               }
+                       }
+               }
+
+               return tags;
+       }
+
+       private Image getCover(URL source, InputStream in) throws IOException {
+
+               List<Entry<String, URL>> chaps = getChapters(source, in, null);
+               if (!chaps.isEmpty()) {
+                       in = Instance.getInstance().getCache().open(chaps.get(0).getValue(), this, true);
+                       String line = getLine(in, " name=\"og:image\"", 0);
+                       if (line != null) {
+                               int pos = -1;
+                               for (int i = 0; i < 3; i++) {
+                                       pos = line.indexOf('"', pos + 1);
+                               }
+
+                               if (pos >= 0) {
+                                       line = line.substring(pos + 1);
+                                       pos = line.indexOf('"');
+                                       if (pos >= 0) {
+                                               line = line.substring(0, pos);
+                                               if (line.contains("/thumb?")) {
+                                                       line = line.replace("/thumb?",
+                                                                       "/auxiliaryContent?type=25&");
+                                                       return getImage(this, null, line);
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+               return null;
+       }
+
+       private String getAuthor(InputStream in) {
+               String author = getLine(in, "class=\"onlinestatus", 0);
+               if (author != null) {
+                       return StringUtils.unhtml(author).trim();
+               }
+
+               return null;
+       }
+
+       private String getTitle(InputStream in) {
+               String title = getLine(in, "class=\"sflabel pagetitle", 0);
+               if (title != null) {
+                       if (title.contains("(series)")) {
+                               title = title.replace("(series)", "");
+                       }
+                       return StringUtils.unhtml(title).trim();
+               }
+
+               return null;
+       }
+
+       @Override
+       protected String getDesc(URL source, InputStream in) throws IOException {
+               return null; // TODO: no description at all? Cannot find one...
+       }
+
+       @Override
+       protected List<Entry<String, URL>> getChapters(URL source, InputStream in,
+                       Progress pg) throws IOException {
+               List<Entry<String, URL>> urls = new ArrayList<Entry<String, URL>>();
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (line.contains("\"/view/") && line.contains("title=")) {
+                               String[] tab = line.split("\"");
+                               if (tab.length > 5) {
+                                       String link = tab[5];
+                                       if (link.startsWith("/")) {
+                                               link = source.getProtocol() + "://" + source.getHost()
+                                                               + link;
+                                       }
+                                       urls.add(new AbstractMap.SimpleEntry<String, URL>(
+                                                       StringUtils.unhtml(line).trim(), guest(link)));
+                               }
+                       }
+               }
+
+               return urls;
+       }
+
+       @Override
+       protected String getChapterContent(URL source, InputStream in, int number,
+                       Progress pg) throws IOException {
+               StringBuilder builder = new StringBuilder();
+
+               String startAt = "id=\"sfContentBody";
+               String endAt = "id=\"recommendationArea";
+               boolean ok = false;
+
+               @SuppressWarnings("resource")
+               Scanner scan = new Scanner(in, "UTF-8");
+               scan.useDelimiter("\\n");
+               while (scan.hasNext()) {
+                       String line = scan.next();
+                       if (!ok && line.contains(startAt)) {
+                               ok = true;
+                       } else if (ok && line.contains(endAt)) {
+                               ok = false;
+                               break;
+                       }
+
+                       if (ok) {
+                               builder.append(line);
+                               builder.append(' ');
+                       }
+               }
+
+               return builder.toString();
+       }
+
+       /**
+        * Return a {@link URL} from the given link, but add the "/guest" part to it
+        * to make sure we don't need to be logged-in to see it.
+        * 
+        * @param link
+        *            the link
+        * 
+        * @return the {@link URL}
+        * 
+        * @throws MalformedURLException
+        *             in case of data error
+        */
+       private URL guest(String link) throws MalformedURLException {
+               if (link.contains("?")) {
+                       if (link.contains("/?")) {
+                               return new URL(link.replace("?", "guest?"));
+                       }
+
+                       return new URL(link.replace("?", "/guest?"));
+               }
+
+               return new URL(link + "/guest");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/supported/package-info.java b/src/be/nikiroo/fanfix/supported/package-info.java
new file mode 100644 (file)
index 0000000..1762e32
--- /dev/null
@@ -0,0 +1,11 @@
+/**
+ * This package contains different implementation of 
+ * {@link be.nikiroo.fanfix.supported.BasicSupport} to cater to different 
+ * sources.
+ * <p>
+ * You are expected to use the static methods from 
+ * {@link be.nikiroo.fanfix.supported.BasicSupport} to get those you need.
+ * 
+ * @author niki
+ */
+package be.nikiroo.fanfix.supported;
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/test/BasicSupportDeprecatedTest.java b/src/be/nikiroo/fanfix/test/BasicSupportDeprecatedTest.java
new file mode 100644 (file)
index 0000000..c8c4b40
--- /dev/null
@@ -0,0 +1,451 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupport_Deprecated;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BasicSupportDeprecatedTest extends TestLauncher {
+       // quote chars
+       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+       public BasicSupportDeprecatedTest(String[] args) {
+               super("BasicSupportDeprecated", args);
+
+               addSeries(new TestLauncher("General", args) {
+                       {
+                               addTest(new TestCase("BasicSupport.makeParagraphs()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+
+                                                       @Override
+                                                       public void fixBlanksBreaks(List<Paragraph> paras) {
+                                                       }
+
+                                                       @Override
+                                                       public List<Paragraph> requotify(Paragraph para) {
+                                                               List<Paragraph> paras = new ArrayList<Paragraph>(
+                                                                               1);
+                                                               paras.add(para);
+                                                               return paras;
+                                                       }
+                                               };
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = support.makeParagraphs(null, "", null);
+                                               assertEquals(
+                                                               "An empty content should not generate paragraphs",
+                                                               0, paras.size());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "Line 1</p><p>Line 2</p><p>Line 3</p>", null);
+                                               assertEquals(5, paras.size());
+                                               assertEquals("Line 1", paras.get(0).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(1)
+                                                               .getType());
+                                               assertEquals("Line 2", paras.get(2).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(3)
+                                                               .getType());
+                                               assertEquals("Line 3", paras.get(4).getContent());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "<p>Line1</p><p>Line2</p><p>Line3</p>", null);
+                                               assertEquals(6, paras.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.removeDoubleBlanks()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>",
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>* * *",
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support.makeParagraphs(null, "1<p>* * *<p>2",
+                                                               null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "1<p><br/><p>* * *<p>2", null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "1<p>* * *<br/><p><br><p>2", null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null,
+                                                               "1<p><br/><br>* * *<br/><p><br><p>2", null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() quotes") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               // sanity check
+                                               para = support.processPara("");
+                                               assertEquals(ParagraphType.BLANK, para.getType());
+                                               //
+
+                                               para = support.processPara("\"Yes, my Lord!\"");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("«Yes, my Lord!»");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("'Yes, my Lord!'");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+
+                                               para = support.processPara("‹Yes, my Lord!›");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase(
+                                               "BasicSupport.processPara() double-simple quotes") {
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               super.setUp();
+
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+
+                                               super.tearDown();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               para = support.processPara("''Yes, my Lord!''");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("‹‹Yes, my Lord!››");
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() apostrophe") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               String text = "Nous étions en été, mais cela aurait être l'hiver quand nous n'étions encore qu'à Aubeuge";
+                                               para = support.processPara(text);
+                                               assertEquals(ParagraphType.NORMAL, para.getType());
+                                               assertEquals(text, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty() {
+                                                       @Override
+                                                       protected boolean isHtml() {
+                                                               return true;
+                                                       }
+                                               };
+
+                                               Paragraph para;
+
+                                               para = support.processPara("«Yes, my Lord!»");
+                                               assertEquals(3, para.getWords());
+
+                                               para = support.processPara("One, twee, trois.");
+                                               assertEquals(3, para.getWords());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.requotify() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportEmpty support = new BasicSupportEmpty();
+
+                                               char openDoubleQuote = Instance.getInstance().getTrans()
+                                                               .getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+                                               char closeDoubleQuote = Instance.getInstance().getTrans()
+                                                               .getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+                                               String content = null;
+                                               Paragraph para = null;
+                                               List<Paragraph> paras = null;
+                                               long words = 0;
+
+                                               content = "One, twee, trois.";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = "Such WoW! So Web2.0! With Colours!";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = openDoubleQuote + "Such a good idea!"
+                                                               + closeDoubleQuote
+                                                               + ", she said. This ought to be a new para.";
+                                               para = new Paragraph(ParagraphType.QUOTE, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals(
+                                                               "Bad words count in a requotified paragraph",
+                                                               para.getWords(), words);
+                                       }
+                               });
+                       }
+               });
+
+               addSeries(new TestLauncher("Text", args) {
+                       {
+                               addTest(new TestCase("Chapter detection simple") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("Chapter detection with String 'Chapter'") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Chapter fout-la-merde" + "\n"
+                                                                               + "\n"//
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+                       }
+               });
+       }
+
+       private class BasicSupportEmpty extends BasicSupport_Deprecated {
+               @Override
+               protected boolean supports(URL url) {
+                       return false;
+               }
+
+               @Override
+               protected boolean isHtml() {
+                       return false;
+               }
+
+               @Override
+               protected MetaData getMeta(URL source, InputStream in)
+                               throws IOException {
+                       return null;
+               }
+
+               @Override
+               protected String getDesc(URL source, InputStream in) throws IOException {
+                       return null;
+               }
+
+               @Override
+               protected List<Entry<String, URL>> getChapters(URL source,
+                               InputStream in, Progress pg) throws IOException {
+                       return null;
+               }
+
+               @Override
+               protected String getChapterContent(URL source, InputStream in,
+                               int number, Progress pg) throws IOException {
+                       return null;
+               }
+
+               @Override
+               // and make it public!
+               public List<Paragraph> makeParagraphs(URL source, String content,
+                               Progress pg) throws IOException {
+                       return super.makeParagraphs(source, content, pg);
+               }
+
+               @Override
+               // and make it public!
+               public void fixBlanksBreaks(List<Paragraph> paras) {
+                       super.fixBlanksBreaks(paras);
+               }
+
+               @Override
+               // and make it public!
+               public Paragraph processPara(String line) {
+                       return super.processPara(line);
+               }
+
+               @Override
+               // and make it public!
+               public List<Paragraph> requotify(Paragraph para) {
+                       return super.requotify(para);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java b/src/be/nikiroo/fanfix/test/BasicSupportUtilitiesTest.java
new file mode 100644 (file)
index 0000000..9cec220
--- /dev/null
@@ -0,0 +1,397 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.StringId;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.supported.BasicSupport;
+import be.nikiroo.fanfix.supported.BasicSupportHelper;
+import be.nikiroo.fanfix.supported.BasicSupportImages;
+import be.nikiroo.fanfix.supported.BasicSupportPara;
+import be.nikiroo.fanfix.supported.SupportType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class BasicSupportUtilitiesTest extends TestLauncher {
+       // quote chars
+       private char openQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_SINGLE_QUOTE);
+       private char closeQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_SINGLE_QUOTE);
+       private char openDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+       private char closeDoubleQuote = Instance.getInstance().getTrans().getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+       
+       public BasicSupportUtilitiesTest(String[] args) {
+               super("BasicSupportUtilities", args);
+               
+               addSeries(new TestLauncher("General", args) {
+                       {
+                               addTest(new TestCase("BasicSupport.makeParagraphs()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic bsPara = new BasicSupportParaPublic() {
+                                                       @Override
+                                                       public void fixBlanksBreaks(List<Paragraph> paras) {
+                                                       }
+
+                                                       @Override
+                                                       public List<Paragraph> requotify(Paragraph para, boolean html) {
+                                                               List<Paragraph> paras = new ArrayList<Paragraph>(
+                                                                               1);
+                                                               paras.add(para);
+                                                               return paras;
+                                                       }
+                                               };
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = bsPara.makeParagraphs(null, null, "", true, null);
+                                               assertEquals(
+                                                               "An empty content should not generate paragraphs",
+                                                               0, paras.size());
+
+                                               paras = bsPara.makeParagraphs(null, null,
+                                                               "Line 1</p><p>Line 2</p><p>Line 3</p>", true, null);
+                                               assertEquals(5, paras.size());
+                                               assertEquals("Line 1", paras.get(0).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(1)
+                                                               .getType());
+                                               assertEquals("Line 2", paras.get(2).getContent());
+                                               assertEquals(ParagraphType.BLANK, paras.get(3)
+                                                               .getType());
+                                               assertEquals("Line 3", paras.get(4).getContent());
+
+                                               paras = bsPara.makeParagraphs(null, null,
+                                                               "<p>Line1</p><p>Line2</p><p>Line3</p>", true, null);
+                                               assertEquals(6, paras.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.removeDoubleBlanks()") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               List<Paragraph> paras = null;
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>",
+                                                                               true,
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support
+                                                               .makeParagraphs(
+                                                                               null,
+                                                                               null,
+                                                                               "<p>Line1</p><p>Line2</p><p>Line3<br/><br><p></p>* * *",
+                                                                               true,
+                                                                               null);
+                                               assertEquals(5, paras.size());
+
+                                               paras = support.makeParagraphs(null, null, "1<p>* * *<p>2",
+                                                               true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null, null,
+                                                               "1<p><br/><p>* * *<p>2", true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null, null,
+                                                               "1<p>* * *<br/><p><br><p>2", true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+
+                                               paras = support.makeParagraphs(null, null,
+                                                               "1<p><br/><br>* * *<br/><p><br><p>2", true, null);
+                                               assertEquals(3, paras.size());
+                                               assertEquals(ParagraphType.BREAK, paras.get(1)
+                                                               .getType());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() quotes") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               // sanity check
+                                               para = support.processPara("", true);
+                                               assertEquals(ParagraphType.BLANK, para.getType());
+                                               //
+
+                                               para = support.processPara("\"Yes, my Lord!\"", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("«Yes, my Lord!»", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("'Yes, my Lord!'", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+
+                                               para = support.processPara("‹Yes, my Lord!›", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openQuote + "Yes, my Lord!" + closeQuote,
+                                                               para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase(
+                                               "BasicSupport.processPara() double-simple quotes") {
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               super.setUp();
+
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+
+                                               super.tearDown();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               para = support.processPara("''Yes, my Lord!''", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+
+                                               para = support.processPara("‹‹Yes, my Lord!››", true);
+                                               assertEquals(ParagraphType.QUOTE, para.getType());
+                                               assertEquals(openDoubleQuote + "Yes, my Lord!"
+                                                               + closeDoubleQuote, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() apostrophe") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               String text = "Nous étions en été, mais cela aurait être l'hiver quand nous n'étions encore qu'à Aubeuge";
+                                               para = support.processPara(text, true);
+                                               assertEquals(ParagraphType.NORMAL, para.getType());
+                                               assertEquals(text, para.getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.processPara() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               Paragraph para;
+
+                                               para = support.processPara("«Yes, my Lord!»", true);
+                                               assertEquals(3, para.getWords());
+
+                                               para = support.processPara("One, twee, trois.", true);
+                                               assertEquals(3, para.getWords());
+                                       }
+                               });
+
+                               addTest(new TestCase("BasicSupport.requotify() words count") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupportParaPublic support = new BasicSupportParaPublic();
+
+                                               char openDoubleQuote = Instance.getInstance().getTrans()
+                                                               .getCharacter(StringId.OPEN_DOUBLE_QUOTE);
+                                               char closeDoubleQuote = Instance.getInstance().getTrans()
+                                                               .getCharacter(StringId.CLOSE_DOUBLE_QUOTE);
+
+                                               String content = null;
+                                               Paragraph para = null;
+                                               List<Paragraph> paras = null;
+                                               long words = 0;
+
+                                               content = "One, twee, trois.";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para, false);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = "Such WoW! So Web2.0! With Colours!";
+                                               para = new Paragraph(ParagraphType.NORMAL, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para, false);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals("Bad words count in a single paragraph",
+                                                               para.getWords(), words);
+
+                                               content = openDoubleQuote + "Such a good idea!"
+                                                               + closeDoubleQuote
+                                                               + ", she said. This ought to be a new para.";
+                                               para = new Paragraph(ParagraphType.QUOTE, content,
+                                                               content.split(" ").length);
+                                               paras = support.requotify(para, false);
+                                               words = 0;
+                                               for (Paragraph p : paras) {
+                                                       words += p.getWords();
+                                               }
+                                               assertEquals(
+                                                               "Bad words count in a requotified paragraph",
+                                                               para.getWords(), words);
+                                       }
+                               });
+                       }
+               });
+
+               addSeries(new TestLauncher("Text", args) {
+                       {
+                               addTest(new TestCase("Chapter detection simple") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+
+                               addTest(new TestCase("Chapter detection with String 'Chapter'") {
+                                       private File tmp;
+
+                                       @Override
+                                       public void setUp() throws Exception {
+                                               tmp = File.createTempFile("fanfix-text-file_", ".test");
+                                               IOUtils.writeSmallFile(tmp.getParentFile(),
+                                                               tmp.getName(), "TITLE"
+                                                                               + "\n"//
+                                                                               + "By nona"
+                                                                               + "\n" //
+                                                                               + "\n" //
+                                                                               + "Chapter 0: Resumé" + "\n" + "\n"
+                                                                               + "'sume." + "\n" + "\n"
+                                                                               + "Chapter 1: chap1" + "\n" + "\n"
+                                                                               + "Chapter fout-la-merde" + "\n"
+                                                                               + "\n"//
+                                                                               + "Fanfan." + "\n" + "\n"
+                                                                               + "Chapter 2: Chap2" + "\n" + "\n" //
+                                                                               + "Tulipe." + "\n");
+                                       }
+
+                                       @Override
+                                       public void tearDown() throws Exception {
+                                               tmp.delete();
+                                       }
+
+                                       @Override
+                                       public void test() throws Exception {
+                                               BasicSupport support = BasicSupport.getSupport(
+                                                               SupportType.TEXT, tmp.toURI().toURL());
+
+                                               Story story = support.process(null);
+
+                                               assertEquals(2, story.getChapters().size());
+                                               assertEquals(1, story.getChapters().get(1)
+                                                               .getParagraphs().size());
+                                               assertEquals("Tulipe.", story.getChapters().get(1)
+                                                               .getParagraphs().get(0).getContent());
+                                       }
+                               });
+                       }
+               });
+       }
+       
+       class BasicSupportParaPublic extends BasicSupportPara {
+               public BasicSupportParaPublic() {
+                       super(new BasicSupportHelper(), new BasicSupportImages());
+               }
+               
+               @Override
+               // and make it public!
+               public Paragraph makeParagraph(BasicSupport support, URL source,
+                               String line, boolean html) {
+                       return super.makeParagraph(support, source, line, html);
+               }
+               
+               @Override
+               // and make it public!
+               public List<Paragraph> makeParagraphs(BasicSupport support,
+                               URL source, String content, boolean html, Progress pg)
+                               throws IOException {
+                       return super.makeParagraphs(support, source, content, html, pg);
+               }
+               
+               @Override
+               // and make it public!
+               public Paragraph processPara(String line, boolean html) {
+                       return super.processPara(line, html);
+               }
+               
+               @Override
+               // and make it public!
+               public List<Paragraph> requotify(Paragraph para, boolean html) {
+                       return super.requotify(para, html);
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/ConversionTest.java b/src/be/nikiroo/fanfix/test/ConversionTest.java
new file mode 100644 (file)
index 0000000..2b22364
--- /dev/null
@@ -0,0 +1,294 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.Main;
+import be.nikiroo.fanfix.output.BasicOutput;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Progress;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class ConversionTest extends TestLauncher {
+       private class MainWithConvert extends Main {
+               @Override
+               public int convert(String urlString, OutputType type, String target,
+                               boolean infoCover, Progress pg) {
+                       return super.convert(urlString, type, target, infoCover, pg);
+               }
+       }
+       
+       private String testUri;
+       private String expectedDir;
+       private String resultDir;
+       private List<BasicOutput.OutputType> realTypes;
+       private Map<String, List<String>> skipCompare;
+       private Map<String, List<String>> skipCompareCross;
+
+       public ConversionTest(String testName, final String testUri,
+                       final String expectedDir, final String resultDir, String[] args) {
+               super("Conversion - " + testName, args);
+
+               this.testUri = testUri;
+               this.expectedDir = expectedDir;
+               this.resultDir = resultDir;
+
+               // Special mode SYSOUT is not a file type (System.out)
+               realTypes = new ArrayList<BasicOutput.OutputType>();
+               for (BasicOutput.OutputType type : BasicOutput.OutputType.values()) {
+                       if (!BasicOutput.OutputType.SYSOUT.equals(type)) {
+                               realTypes.add(type);
+                       }
+               }
+
+               if (!testUri.startsWith("http://") && !testUri.startsWith("https://")) {
+                       addTest(new TestCase("Read the test file") {
+                               @Override
+                               public void test() throws Exception {
+                                       assertEquals("The test file \"" + testUri
+                                                       + "\" cannot be found", true,
+                                                       new File(testUri).exists());
+                               }
+                       });
+               }
+
+               addTest(new TestCase("Assure directories exist") {
+                       @Override
+                       public void test() throws Exception {
+                               new File(expectedDir).mkdirs();
+                               new File(resultDir).mkdirs();
+                               assertEquals("The Expected directory \"" + expectedDir
+                                               + "\" cannot be created", true,
+                                               new File(expectedDir).exists());
+                               assertEquals("The Result directory \"" + resultDir
+                                               + "\" cannot be created", true,
+                                               new File(resultDir).exists());
+                       }
+               });
+
+               for (BasicOutput.OutputType type : realTypes) {
+                       addTest(getTestFor(type));
+               }
+       }
+
+       @Override
+       protected void start() throws Exception {
+               skipCompare = new HashMap<String, List<String>>();
+               skipCompareCross = new HashMap<String, List<String>>();
+
+               skipCompare.put("epb.ncx", Arrays.asList(
+                               "               <meta name=\"dtb:uid\" content=",
+                               "               <meta name=\"epub-creator\" content=\""));
+               skipCompare.put("epb.opf", Arrays.asList("      <dc:subject>",
+                               "      <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"));
+               skipCompare.put(".info", Arrays.asList("CREATION_DATE=",
+                               "URL=\"file:/", "UUID=EPUBCREATOR=\"", ""));
+               skipCompare.put("URL", Arrays.asList("file:/"));
+
+               for (String key : skipCompare.keySet()) {
+                       skipCompareCross.put(key, skipCompare.get(key));
+               }
+
+               skipCompareCross.put(".info", Arrays.asList(""));
+               skipCompareCross.put("epb.opf", Arrays.asList("      <dc:"));
+               skipCompareCross.put("title.xhtml",
+                               Arrays.asList("                 <div class=\"type\">"));
+               skipCompareCross.put("index.html",
+                               Arrays.asList("                 <div class=\"type\">"));
+               skipCompareCross.put("URL", Arrays.asList(""));
+       }
+
+       @Override
+       protected void stop() throws Exception {
+       }
+
+       private TestCase getTestFor(final BasicOutput.OutputType type) {
+               return new TestCase(type + " output mode") {
+                       @Override
+                       public void test() throws Exception {
+                               File target = generate(this, testUri, new File(resultDir), type);
+                               target = new File(target.getAbsolutePath()
+                                               + type.getDefaultExtension(false));
+
+                               // Check conversion:
+                               compareFiles(this, new File(expectedDir), new File(resultDir),
+                                               type, "Generate " + type);
+
+                               // LATEX not supported as input
+                               if (BasicOutput.OutputType.LATEX.equals(type)) {
+                                       return;
+                               }
+
+                               // Cross-checks:
+                               for (BasicOutput.OutputType crossType : realTypes) {
+                                       File crossDir = Test.tempFiles
+                                                       .createTempDir("cross-result");
+
+                                       generate(this, target.getAbsolutePath(), crossDir,
+                                                       crossType);
+                                       compareFiles(this, new File(resultDir), crossDir,
+                                                       crossType, "Cross compare " + crossType
+                                                                       + " generated from " + type);
+                               }
+                       }
+               };
+       }
+
+       private File generate(TestCase testCase, String testUri, File resultDir,
+                       BasicOutput.OutputType type) throws Exception {
+               final List<String> errors = new ArrayList<String>();
+
+               TraceHandler previousTraceHandler = Instance.getInstance().getTraceHandler();
+               Instance.getInstance().setTraceHandler(new TraceHandler(true, true, 0) {
+                       @Override
+                       public void error(String message) {
+                               errors.add(message);
+                       }
+
+                       @Override
+                       public void error(Exception e) {
+                               error(" ");
+                               for (Throwable t = e; t != null; t = t.getCause()) {
+                                       error(((t == e) ? "(" : "..caused by: (")
+                                                       + t.getClass().getSimpleName() + ") "
+                                                       + t.getMessage());
+                                       for (StackTraceElement s : t.getStackTrace()) {
+                                               error("\t" + s.toString());
+                                       }
+                               }
+                       }
+               });
+
+               try {
+                       File target = new File(resultDir, type.toString());
+                       int code = new MainWithConvert().convert(testUri, type,
+                                       target.getAbsolutePath(), false, null);
+
+                       String error = "";
+                       for (String err : errors) {
+                               if (!error.isEmpty())
+                                       error += "\n";
+                               error += err;
+                       }
+                       testCase.assertEquals("The conversion returned an error message: "
+                                       + error, 0, errors.size());
+                       if (code != 0) {
+                               testCase.fail("The conversion failed with return code: " + code);
+                       }
+
+                       return target;
+               } finally {
+                       Instance.getInstance().setTraceHandler(previousTraceHandler);
+               }
+       }
+
+       private void compareFiles(TestCase testCase, File expectedDir,
+                       File resultDir, final BasicOutput.OutputType limitTiFiles,
+                       final String errMess) throws Exception {
+
+               Map<String, List<String>> skipCompare = errMess.startsWith("Cross") ? this.skipCompareCross
+                               : this.skipCompare;
+
+               FilenameFilter filter = null;
+               if (limitTiFiles != null) {
+                       filter = new FilenameFilter() {
+                               @Override
+                               public boolean accept(File dir, String name) {
+                                       return name.toLowerCase().startsWith(
+                                                       limitTiFiles.toString().toLowerCase());
+                               }
+                       };
+               }
+
+               List<String> resultFiles;
+               List<String> expectedFiles;
+               {
+                       String[] resultArr = resultDir.list(filter);
+                       Arrays.sort(resultArr);
+                       resultFiles = Arrays.asList(resultArr);
+                       String[] expectedArr = expectedDir.list(filter);
+                       Arrays.sort(expectedArr);
+                       expectedFiles = Arrays.asList(expectedArr);
+               }
+
+               testCase.assertEquals(errMess, expectedFiles, resultFiles);
+
+               for (int i = 0; i < resultFiles.size(); i++) {
+                       File expected = new File(expectedDir, expectedFiles.get(i));
+                       File result = new File(resultDir, resultFiles.get(i));
+
+                       testCase.assertEquals(errMess + ": type mismatch: expected a "
+                                       + (expected.isDirectory() ? "directory" : "file")
+                                       + ", received a "
+                                       + (result.isDirectory() ? "directory" : "file"),
+                                       expected.isDirectory(), result.isDirectory());
+
+                       if (expected.isDirectory()) {
+                               compareFiles(testCase, expected, result, null, errMess);
+                               continue;
+                       }
+
+                       if (expected.getName().endsWith(".cbz")
+                                       || expected.getName().endsWith(".epub")) {
+                               File tmpExpected = Test.tempFiles.createTempDir(expected
+                                               .getName() + "[zip-content]");
+                               File tmpResult = Test.tempFiles.createTempDir(result.getName()
+                                               + "[zip-content]");
+                               IOUtils.unzip(expected, tmpExpected);
+                               IOUtils.unzip(result, tmpResult);
+                               compareFiles(testCase, tmpExpected, tmpResult, null, errMess);
+                       } else {
+                               List<String> expectedLines = Arrays.asList(IOUtils
+                                               .readSmallFile(expected).split("\n"));
+                               List<String> resultLines = Arrays.asList(IOUtils.readSmallFile(
+                                               result).split("\n"));
+
+                               String name = expected.getAbsolutePath();
+                               if (name.startsWith(expectedDir.getAbsolutePath())) {
+                                       name = expectedDir.getName()
+                                                       + name.substring(expectedDir.getAbsolutePath()
+                                                                       .length());
+                               }
+
+                               testCase.assertEquals(errMess + ": " + name
+                                               + ": the number of lines is not the same",
+                                               expectedLines.size(), resultLines.size());
+
+                               for (int j = 0; j < expectedLines.size(); j++) {
+                                       String expectedLine = expectedLines.get(j);
+                                       String resultLine = resultLines.get(j);
+
+                                       boolean skip = false;
+                                       for (Entry<String, List<String>> skipThose : skipCompare
+                                                       .entrySet()) {
+                                               for (String skipStart : skipThose.getValue()) {
+                                                       if (name.endsWith(skipThose.getKey())
+                                                                       && expectedLine.startsWith(skipStart)
+                                                                       && resultLine.startsWith(skipStart)) {
+                                                               skip = true;
+                                                       }
+                                               }
+                                       }
+
+                                       if (skip) {
+                                               continue;
+                                       }
+
+                                       testCase.assertEquals(errMess + ": line " + (j + 1)
+                                                       + " is not the same in file " + name, expectedLine,
+                                                       resultLine);
+                               }
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/LibraryTest.java b/src/be/nikiroo/fanfix/test/LibraryTest.java
new file mode 100644 (file)
index 0000000..da44438
--- /dev/null
@@ -0,0 +1,258 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.BasicLibrary;
+import be.nikiroo.fanfix.library.LocalLibrary;
+import be.nikiroo.fanfix.output.BasicOutput.OutputType;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.test.TestCase;
+import be.nikiroo.utils.test.TestLauncher;
+
+class LibraryTest extends TestLauncher {
+       private BasicLibrary lib;
+       private File tmp;
+
+       public LibraryTest(String[] args) {
+               super("Library", args);
+
+               final String luid1 = "001"; // A
+               final String luid2 = "002"; // B
+               final String luid3 = "003"; // B then A, then B
+               final String name1 = "My story 1";
+               final String name2 = "My story 2";
+               final String name3 = "My story 3";
+               final String name3ex = "My story 3 [edited]";
+               final String source1 = "Source A";
+               final String source2 = "Source B";
+               final String author1 = "Unknown author";
+               final String author2 = "Another other otter author";
+
+               final String errMess = "The resulting stories in the list are not what we expected";
+
+               addSeries(new TestLauncher("Local", args) {
+                       {
+                               addTest(new TestCase("getList") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = lib.getList().getMetas();
+                                               assertEquals(errMess, Arrays.asList(),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("save") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               lib.save(story(luid1, name1, source1, author1), luid1,
+                                                               null);
+
+                                               List<MetaData> metas = lib.getList().getMetas();
+                                               assertEquals(errMess, Arrays.asList(name1),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("save more") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               lib.save(story(luid2, name2, source2, author1), luid2,
+                                                               null);
+
+                                               metas = lib.getList().getMetas();
+                                               assertEquals(errMess, Arrays.asList(name1, name2),
+                                                               titlesAsList(metas));
+
+                                               lib.save(story(luid3, name3, source2, author1), luid3,
+                                                               null);
+
+                                               metas = lib.getList().getMetas();
+                                               assertEquals(errMess,
+                                                               Arrays.asList(name1, name2, name3),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("save override luid (change author)") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               // same luid as a previous one
+                                               lib.save(story(luid3, name3ex, source2, author2),
+                                                               luid3, null);
+
+                                               List<MetaData> metas = lib.getList().getMetas();
+                                               assertEquals(errMess,
+                                                               Arrays.asList(name1, name2, name3ex),
+                                                               titlesAsList(metas));
+                                       }
+                               });
+
+                               addTest(new TestCase("getList with results") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = lib.getList().getMetas();
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("getList by source") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               metas = lib.getList().filter(source1, null, null);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getList().filter(source2, null, null);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getList().filter((String)null, null, null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("getList by author") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               metas = lib.getList().filter(null, author1, null);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getList().filter(null, author2, null);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getList().filter((String)null, null, null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("changeType") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               lib.changeSource(luid3, source1, null);
+
+                                               metas = lib.getList().filter(source1, null, null);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getList().filter(source2, null, null);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getList().filter((String)null, null, null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+
+                               addTest(new TestCase("save override luid (change source)") {
+                                       @Override
+                                       public void test() throws Exception {
+                                               List<MetaData> metas = null;
+
+                                               // same luid as a previous one
+                                               lib.save(story(luid3, "My story 3", source2, author2),
+                                                               luid3, null);
+
+                                               metas = lib.getList().filter(source1, null, null);
+                                               assertEquals(1, metas.size());
+
+                                               metas = lib.getList().filter(source2, null, null);
+                                               assertEquals(2, metas.size());
+
+                                               metas = lib.getList().filter((String)null, null, null);
+                                               assertEquals(3, metas.size());
+                                       }
+                               });
+                       }
+               });
+       }
+
+       @Override
+       protected void start() throws Exception {
+               tmp = File.createTempFile(".test-fanfix", ".library");
+               tmp.delete();
+               tmp.mkdir();
+
+               lib = new LocalLibrary(tmp, OutputType.INFO_TEXT, OutputType.CBZ);
+       }
+
+       @Override
+       protected void stop() throws Exception {
+               IOUtils.deltree(tmp);
+       }
+
+       /**
+        * Return the (sorted) list of titles present in this list of
+        * {@link MetaData}s.
+        * 
+        * @param metas
+        *            the meta
+        * 
+        * @return the sorted list
+        */
+       private List<String> titlesAsList(List<MetaData> metas) {
+               List<String> list = new ArrayList<String>();
+               for (MetaData meta : metas) {
+                       list.add(meta.getTitle());
+               }
+
+               Collections.sort(list);
+               return list;
+       }
+
+       private Story story(String luid, String title, String source, String author) {
+               Story story = new Story();
+
+               MetaData meta = new MetaData();
+               meta.setLuid(luid);
+               meta.setTitle(title);
+               meta.setSource(source);
+               meta.setAuthor(author);
+               story.setMeta(meta);
+
+               Chapter resume = chapter(0, "Resume");
+               meta.setResume(resume);
+
+               List<Chapter> chapters = new ArrayList<Chapter>();
+               chapters.add(chapter(1, "Chap 1"));
+               chapters.add(chapter(2, "Chap 2"));
+               story.setChapters(chapters);
+
+               long words = 0;
+               for (Chapter chap : story.getChapters()) {
+                       words += chap.getWords();
+               }
+               meta.setWords(words);
+
+               return story;
+       }
+
+       private Chapter chapter(int number, String name) {
+               Chapter chapter = new Chapter(number, name);
+
+               List<Paragraph> paragraphs = new ArrayList<Paragraph>();
+               paragraphs.add(new Paragraph(Paragraph.ParagraphType.NORMAL,
+                               "some words in this paragraph please thank you", 8));
+
+               chapter.setParagraphs(paragraphs);
+
+               long words = 0;
+               for (Paragraph para : chapter.getParagraphs()) {
+                       words += para.getWords();
+               }
+               chapter.setWords(words);
+
+               return chapter;
+       }
+}
diff --git a/src/be/nikiroo/fanfix/test/Test.java b/src/be/nikiroo/fanfix/test/Test.java
new file mode 100644 (file)
index 0000000..5ec24a4
--- /dev/null
@@ -0,0 +1,177 @@
+package be.nikiroo.fanfix.test;
+
+import java.io.File;
+import java.io.IOException;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.ConfigBundle;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.TempFiles;
+import be.nikiroo.utils.resources.Bundles;
+import be.nikiroo.utils.test.TestLauncher;
+
+/**
+ * Tests for Fanfix.
+ * 
+ * @author niki
+ */
+public class Test extends TestLauncher {
+       //
+       // 4 files can control the test:
+       // - test/VERBOSE: enable verbose mode
+       // - test/OFFLINE: to forbid any downloading
+       // - test/URLS: to allow testing URLs
+       // - test/FORCE_REFRESH: to force a clear of the cache
+       //
+       // Note that test/CACHE can be kept, as it will contain all internet related
+       // files you need (if you allow URLs, run the test once which will populate
+       // the CACHE then go OFFLINE, it will still work).
+       //
+       // The test files will be:
+       // - test/*.url: URL to download in text format, content = URL
+       // - test/*.story: text mode story, content = story
+       //
+
+       /**
+        * The temporary files handler.
+        */
+       static TempFiles tempFiles;
+
+       /**
+        * Create the Fanfix {@link TestLauncher}.
+        * 
+        * @param args
+        *            the arguments to configure the number of columns and the ok/ko
+        *            {@link String}s
+        * @param urlsAllowed
+        *            allow testing URLs (<tt>.url</tt> files)
+        * 
+        * @throws IOException
+        */
+       public Test(String[] args, boolean urlsAllowed) throws IOException {
+               super("Fanfix", args);
+               Instance.getInstance().setTraceHandler(null);
+               addSeries(new BasicSupportUtilitiesTest(args));
+               addSeries(new BasicSupportDeprecatedTest(args));
+               addSeries(new LibraryTest(args));
+
+               File sources = new File("test/");
+               if (sources.isDirectory()) {
+                       for (File file : sources.listFiles()) {
+                               if (file.isDirectory()) {
+                                       continue;
+                               }
+
+                               String expectedDir = new File(file.getParentFile(), "expected_"
+                                               + file.getName()).getAbsolutePath();
+                               String resultDir = new File(file.getParentFile(), "result_"
+                                               + file.getName()).getAbsolutePath();
+
+                               String uri;
+                               if (urlsAllowed && file.getName().endsWith(".url")) {
+                                       uri = IOUtils.readSmallFile(file).trim();
+                               } else if (file.getName().endsWith(".story")) {
+                                       uri = file.getAbsolutePath();
+                               } else {
+                                       continue;
+                               }
+
+                               addSeries(new ConversionTest(file.getName(), uri, expectedDir,
+                                               resultDir, args));
+                       }
+               }
+       }
+
+       /**
+        * Main entry point of the program.
+        * 
+        * @param args
+        *            the arguments passed to the {@link TestLauncher}s.
+        * @throws IOException
+        *             in case of I/O error
+        */
+       static public void main(String[] args) throws IOException {
+               Instance.init();
+
+               // Verbose mode:
+               boolean verbose = new File("test/VERBOSE").exists();
+
+               // Can force refresh
+               boolean forceRefresh = new File("test/FORCE_REFRESH").exists();
+
+               // Allow URLs:
+               boolean urlsAllowed = new File("test/URLS").exists();
+
+               
+               // Only download files if allowed:
+               boolean offline = new File("test/OFFLINE").exists();
+               Instance.getInstance().getCache().setOffline(offline);
+
+
+               
+               int result = 0;
+               tempFiles = new TempFiles("fanfix-test");
+               try {
+                       File tmpConfig = tempFiles.createTempDir("fanfix-config");
+                       File localCache = new File("test/CACHE");
+                       prepareCache(localCache, forceRefresh);
+
+                       ConfigBundle config = new ConfigBundle();
+                       Bundles.setDirectory(tmpConfig.getAbsolutePath());
+                       config.setString(Config.CACHE_DIR, localCache.getAbsolutePath());
+                       config.setInteger(Config.CACHE_MAX_TIME_STABLE, -1);
+                       config.setInteger(Config.CACHE_MAX_TIME_CHANGING, -1);
+                       config.updateFile(tmpConfig.getPath());
+                       System.setProperty("CONFIG_DIR", tmpConfig.getAbsolutePath());
+
+                       Instance.init(true);
+                       Instance.getInstance().getCache().setOffline(offline);
+
+                       TestLauncher tests = new Test(args, urlsAllowed);
+                       tests.setDetails(verbose);
+
+                       result = tests.launch();
+
+                       IOUtils.deltree(tmpConfig);
+                       prepareCache(localCache, forceRefresh);
+               } finally {
+                       // Test temp files
+                       tempFiles.close();
+
+                       // This is usually done in Fanfix.Main:
+                       Instance.getInstance().getTempFiles().close();
+               }
+
+               System.exit(result);
+       }
+
+       /**
+        * Prepare the cache (or clean it up).
+        * <p>
+        * The cache directory will always exist if this method succeed
+        * 
+        * @param localCache
+        *            the cache directory
+        * @param forceRefresh
+        *            TRUE to force acache refresh (delete all files)
+        * 
+        * @throw IOException if the cache cannot be created
+        */
+       private static void prepareCache(File localCache, boolean forceRefresh)
+                       throws IOException {
+               // if needed
+               localCache.mkdirs();
+
+               if (!localCache.isDirectory()) {
+                       throw new IOException("Cannot get a cache");
+               }
+
+               // delete local cached files (_*) or all files if forceRefresh
+               for (File f : localCache.listFiles()) {
+                       if (forceRefresh || f.getName().startsWith("_")) {
+                               IOUtils.deltree(f);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TBrowsableWidget.java b/src/be/nikiroo/jexer/TBrowsableWidget.java
new file mode 100644 (file)
index 0000000..44fa710
--- /dev/null
@@ -0,0 +1,418 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 2
+ */
+package be.nikiroo.jexer;
+
+import static jexer.TKeypress.kbBackTab;
+import static jexer.TKeypress.kbDown;
+import static jexer.TKeypress.kbEnd;
+import static jexer.TKeypress.kbEnter;
+import static jexer.TKeypress.kbHome;
+import static jexer.TKeypress.kbLeft;
+import static jexer.TKeypress.kbPgDn;
+import static jexer.TKeypress.kbPgUp;
+import static jexer.TKeypress.kbRight;
+import static jexer.TKeypress.kbShiftTab;
+import static jexer.TKeypress.kbTab;
+import static jexer.TKeypress.kbUp;
+import jexer.THScroller;
+import jexer.TScrollableWidget;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+/**
+ * This class represents a browsable {@link TWidget}, that is, a {@link TWidget}
+ * where you can use the keyboard or mouse to browse to one line to the next, or
+ * from left t right.
+ * 
+ * @author niki
+ */
+abstract public class TBrowsableWidget extends TScrollableWidget {
+       private int selectedRow;
+       private int selectedColumn;
+       private int yOffset;
+
+       /**
+        * The number of rows in this {@link TWidget}.
+        * 
+        * @return the number of rows
+        */
+       abstract protected int getRowCount();
+
+       /**
+        * The number of columns in this {@link TWidget}.
+        * 
+        * @return the number of columns
+        */
+       abstract protected int getColumnCount();
+
+       /**
+        * The virtual width of this {@link TWidget}, that is, the total width it
+        * can take to display all the data.
+        * 
+        * @return the width
+        */
+       abstract int getVirtualWidth();
+
+       /**
+        * The virtual height of this {@link TWidget}, that is, the total width it
+        * can take to display all the data.
+        * 
+        * @return the height
+        */
+       abstract int getVirtualHeight();
+
+       /**
+        * Basic setup of this class (called by all constructors)
+        */
+       private void setup() {
+               vScroller = new TVScroller(this, 0, 0, 1);
+               hScroller = new THScroller(this, 0, 0, 1);
+               fixScrollers();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        */
+       protected TBrowsableWidget(final TWidget parent) {
+               super(parent);
+               setup();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        * @param x
+        *            column relative to parent
+        * @param y
+        *            row relative to parent
+        * @param width
+        *            width of widget
+        * @param height
+        *            height of widget
+        */
+       protected TBrowsableWidget(final TWidget parent, final int x, final int y,
+                       final int width, final int height) {
+               super(parent, x, y, width, height);
+               setup();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        * @param enabled
+        *            if true assume enabled
+        */
+       protected TBrowsableWidget(final TWidget parent, final boolean enabled) {
+               super(parent, enabled);
+               setup();
+       }
+
+       /**
+        * Create a new {@link TBrowsableWidget} linked to the given {@link TWidget}
+        * parent.
+        * 
+        * @param parent
+        *            parent widget
+        * @param enabled
+        *            if true assume enabled
+        * @param x
+        *            column relative to parent
+        * @param y
+        *            row relative to parent
+        * @param width
+        *            width of widget
+        * @param height
+        *            height of widget
+        */
+       protected TBrowsableWidget(final TWidget parent, final boolean enabled,
+                       final int x, final int y, final int width, final int height) {
+               super(parent, enabled, x, y, width, height);
+               setup();
+       }
+
+       /**
+        * The currently selected row (or -1 if no row is selected).
+        * 
+        * @return the selected row
+        */
+       public int getSelectedRow() {
+               return selectedRow;
+       }
+
+       /**
+        * The currently selected row (or -1 if no row is selected).
+        * <p>
+        * You may want to call {@link TBrowsableWidget#reflowData()} when done to
+        * see the changes.
+        * 
+        * @param selectedRow
+        *            the new selected row
+        * 
+        * @throws IndexOutOfBoundsException
+        *             when the index is out of bounds
+        */
+       public void setSelectedRow(int selectedRow) {
+               if (selectedRow < -1 || selectedRow >= getRowCount()) {
+                       throw new IndexOutOfBoundsException(String.format(
+                                       "Cannot set row %d on a table with %d rows", selectedRow,
+                                       getRowCount()));
+               }
+
+               this.selectedRow = selectedRow;
+       }
+
+       /**
+        * The currently selected column (or -1 if no column is selected).
+        * 
+        * @return the new selected column
+        */
+       public int getSelectedColumn() {
+               return selectedColumn;
+       }
+
+       /**
+        * The currently selected column (or -1 if no column is selected).
+        * <p>
+        * You may want to call {@link TBrowsableWidget#reflowData()} when done to
+        * see the changes.
+        * 
+        * @param selectedColumn
+        *            the new selected column
+        * 
+        * @throws IndexOutOfBoundsException
+        *             when the index is out of bounds
+        */
+       public void setSelectedColumn(int selectedColumn) {
+               if (selectedColumn < -1 || selectedColumn >= getColumnCount()) {
+                       throw new IndexOutOfBoundsException(String.format(
+                                       "Cannot set column %d on a table with %d columns",
+                                       selectedColumn, getColumnCount()));
+               }
+
+               this.selectedColumn = selectedColumn;
+       }
+
+       /**
+        * An offset on the Y position of the table, i.e., the number of rows to
+        * skip so the control can draw that many rows always on top.
+        * 
+        * @return the offset
+        */
+       public int getYOffset() {
+               return yOffset;
+       }
+
+       /**
+        * An offset on the Y position of the table, i.e., the number of rows that
+        * should always stay on top.
+        * 
+        * @param yOffset
+        *            the new offset
+        */
+       public void setYOffset(int yOffset) {
+               this.yOffset = yOffset;
+       }
+
+       @SuppressWarnings("unused")
+       public void dispatchMove(int fromRow, int toRow) {
+               reflowData();
+       }
+
+       @SuppressWarnings("unused")
+       public void dispatchEnter(int selectedRow) {
+               reflowData();
+       }
+
+       @Override
+       public void onMouseDown(final TMouseEvent mouse) {
+               if (mouse.isMouseWheelUp()) {
+                       vScroller.decrement();
+                       return;
+               }
+               if (mouse.isMouseWheelDown()) {
+                       vScroller.increment();
+                       return;
+               }
+
+               if ((mouse.getX() < getWidth() - 1) && (mouse.getY() < getHeight() - 1)) {
+                       if (vScroller.getValue() + mouse.getY() < getRowCount()) {
+                               selectedRow = vScroller.getValue() + mouse.getY()
+                                               - getYOffset();
+                       }
+                       dispatchEnter(selectedRow);
+                       return;
+               }
+
+               // Pass to children
+               super.onMouseDown(mouse);
+       }
+
+       @Override
+       public void onKeypress(final TKeypressEvent keypress) {
+               int maxX = getRowCount();
+               int prevSelectedRow = selectedRow;
+
+               int firstLineIndex = vScroller.getValue() - getYOffset() + 2;
+               int lastLineIndex = firstLineIndex - hScroller.getHeight()
+                               + getHeight() - 2 - 2;
+
+               if (keypress.equals(kbLeft)) {
+                       hScroller.decrement();
+               } else if (keypress.equals(kbRight)) {
+                       hScroller.increment();
+               } else if (keypress.equals(kbUp)) {
+                       if (maxX > 0 && selectedRow < maxX) {
+                               if (selectedRow > 0) {
+                                       if (selectedRow <= firstLineIndex) {
+                                               vScroller.decrement();
+                                       }
+                                       selectedRow--;
+                               } else {
+                                       selectedRow = 0;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbDown)) {
+                       if (maxX > 0) {
+                               if (selectedRow >= 0) {
+                                       if (selectedRow < maxX - 1) {
+                                               selectedRow++;
+                                               if (selectedRow >= lastLineIndex) {
+                                                       vScroller.increment();
+                                               }
+                                       }
+                               } else {
+                                       selectedRow = 0;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbPgUp)) {
+                       if (selectedRow >= 0) {
+                               vScroller.bigDecrement();
+                               selectedRow -= getHeight() - 1;
+                               if (selectedRow < 0) {
+                                       selectedRow = 0;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbPgDn)) {
+                       if (selectedRow >= 0) {
+                               vScroller.bigIncrement();
+                               selectedRow += getHeight() - 1;
+                               if (selectedRow > getRowCount() - 1) {
+                                       selectedRow = getRowCount() - 1;
+                               }
+
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbHome)) {
+                       if (getRowCount() > 0) {
+                               vScroller.toTop();
+                               selectedRow = 0;
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbEnd)) {
+                       if (getRowCount() > 0) {
+                               vScroller.toBottom();
+                               selectedRow = getRowCount() - 1;
+                               dispatchMove(prevSelectedRow, selectedRow);
+                       }
+               } else if (keypress.equals(kbTab)) {
+                       getParent().switchWidget(true);
+               } else if (keypress.equals(kbShiftTab) || keypress.equals(kbBackTab)) {
+                       getParent().switchWidget(false);
+               } else if (keypress.equals(kbEnter)) {
+                       if (selectedRow >= 0) {
+                               dispatchEnter(selectedRow);
+                       }
+               } else {
+                       // Pass other keys (tab etc.) on
+                       super.onKeypress(keypress);
+               }
+       }
+
+       @Override
+       public void onResize(TResizeEvent event) {
+               super.onResize(event);
+               reflowData();
+       }
+
+       @Override
+       public void reflowData() {
+               super.reflowData();
+               fixScrollers();
+       }
+
+       private void fixScrollers() {
+               int width = getWidth() - 1; // vertical prio
+               int height = getHeight();
+
+               // TODO: why did we do that before?
+               if (false) {
+                       width -= 2;
+                       height = -1;
+               }
+
+               int x = Math.max(0, width);
+               int y = Math.max(0, height - 1);
+
+               vScroller.setX(x);
+               vScroller.setHeight(height);
+               hScroller.setY(y);
+               hScroller.setWidth(width);
+
+               // TODO why did we use to add 2?
+               // + 2 (for the border of the window)
+
+               // virtual_size
+               // + the other scroll bar size
+               vScroller.setTopValue(0);
+               vScroller.setBottomValue(Math.max(0, getVirtualHeight() - getHeight()
+                               + hScroller.getHeight()));
+               hScroller.setLeftValue(0);
+               hScroller.setRightValue(Math.max(0, getVirtualWidth() - getWidth()
+                               + vScroller.getWidth()));
+       }
+}
diff --git a/src/be/nikiroo/jexer/TSizeConstraint.java b/src/be/nikiroo/jexer/TSizeConstraint.java
new file mode 100644 (file)
index 0000000..bfdbb3a
--- /dev/null
@@ -0,0 +1,92 @@
+package be.nikiroo.jexer;
+
+import java.util.List;
+
+import jexer.TScrollableWidget;
+import jexer.TWidget;
+import jexer.event.TResizeEvent;
+import jexer.event.TResizeEvent.Type;
+
+public class TSizeConstraint {
+       private TWidget widget;
+       private Integer x1;
+       private Integer y1;
+       private Integer x2;
+       private Integer y2;
+
+       // TODO: include in the window classes I use?
+
+       public TSizeConstraint(TWidget widget, Integer x1, Integer y1, Integer x2,
+                       Integer y2) {
+               this.widget = widget;
+               this.x1 = x1;
+               this.y1 = y1;
+               this.x2 = x2;
+               this.y2 = y2;
+       }
+
+       public TWidget getWidget() {
+               return widget;
+       }
+
+       public Integer getX1() {
+               if (x1 != null && x1 < 0)
+                       return widget.getParent().getWidth() + x1;
+               return x1;
+       }
+
+       public Integer getY1() {
+               if (y1 != null && y1 < 0)
+                       return widget.getParent().getHeight() + y1;
+               return y1;
+       }
+
+       public Integer getX2() {
+               if (x2 != null && x2 <= 0)
+                       return widget.getParent().getWidth() - 2 + x2;
+               return x2;
+       }
+
+       public Integer getY2() {
+               if (y2 != null && y2 <= 0)
+                       return widget.getParent().getHeight() - 2 + y2;
+               return y2;
+       }
+
+       // coordinates < 0 = from the other side
+       //              x2 or y2 = 0 = max size
+       //              coordinate NULL = do not work on that side at all
+       static public void setSize(List<TSizeConstraint> sizeConstraints, TWidget child,
+                       Integer x1, Integer y1, Integer x2, Integer y2) {
+               sizeConstraints.add(new TSizeConstraint(child, x1, y1, x2, y2));
+       }
+
+       static public void resize(List<TSizeConstraint> sizeConstraints) {
+               for (TSizeConstraint sizeConstraint : sizeConstraints) {
+                       TWidget widget = sizeConstraint.getWidget();
+                       Integer x1 = sizeConstraint.getX1();
+                       Integer y1 = sizeConstraint.getY1();
+                       Integer x2 = sizeConstraint.getX2();
+                       Integer y2 = sizeConstraint.getY2();
+
+                       if (x1 != null)
+                               widget.setX(x1);
+                       if (y1 != null)
+                               widget.setY(y1);
+
+                       if (x2 != null)
+                               widget.setWidth(x2 - widget.getX());
+                       if (y2 != null)
+                               widget.setHeight(y2 - widget.getY());
+
+                       // Resize the text field
+                       // TODO: why setW/setH/reflow not enough for the scrollbars?
+                       widget.onResize(new TResizeEvent(Type.WIDGET, widget.getWidth(),
+                                       widget.getHeight()));
+
+                       if (widget instanceof TScrollableWidget) {
+                               ((TScrollableWidget) widget).reflowData();
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTable.java b/src/be/nikiroo/jexer/TTable.java
new file mode 100644 (file)
index 0000000..45e5df2
--- /dev/null
@@ -0,0 +1,516 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.swing.table.TableModel;
+
+import be.nikiroo.jexer.TTableCellRenderer.CellRendererMode;
+import jexer.TAction;
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+
+/**
+ * A table widget to display and browse through tabular data.
+ * <p>
+ * Currently, you can only select a line (a row) at a time, but the data you
+ * present is still tabular. You also access the data in a tabular way (by
+ * <tt>(raw,column)</tt>).
+ * 
+ * @author niki
+ */
+public class TTable extends TBrowsableWidget {
+       // Default renderers use text mode
+       static private TTableCellRenderer defaultSeparatorRenderer = new TTableCellRendererText(
+                       CellRendererMode.SEPARATOR);
+       static private TTableCellRenderer defaultHeaderRenderer = new TTableCellRendererText(
+                       CellRendererMode.HEADER);
+       static private TTableCellRenderer defaultHeaderSeparatorRenderer = new TTableCellRendererText(
+                       CellRendererMode.HEADER_SEPARATOR);
+
+       private boolean showHeader;
+
+       private List<TTableColumn> columns = new ArrayList<TTableColumn>();
+       private TableModel model;
+
+       private int selectedColumn;
+
+       private TTableCellRenderer separatorRenderer;
+       private TTableCellRenderer headerRenderer;
+       private TTableCellRenderer headerSeparatorRenderer;
+
+       /**
+        * The action to perform when the user selects an item (clicks or enter).
+        */
+       private TAction enterAction = null;
+
+       /**
+        * The action to perform when the user navigates with keyboard.
+        */
+       private TAction moveAction = null;
+
+       /**
+        * Create a new {@link TTable}.
+        * 
+        * @param parent
+        *            the parent widget
+        * @param x
+        *            the X position
+        * @param y
+        *            the Y position
+        * @param width
+        *            the width of the {@link TTable}
+        * @param height
+        *            the height of the {@link TTable}
+        * @param enterAction
+        *            an action to call when a cell is selected
+        * @param moveAction
+        *            an action to call when the currently active cell is changed
+        */
+       public TTable(TWidget parent, int x, int y, int width, int height,
+                       final TAction enterAction, final TAction moveAction) {
+               this(parent, x, y, width, height, enterAction, moveAction, null, false);
+       }
+
+       /**
+        * Create a new {@link TTable}.
+        * 
+        * @param parent
+        *            the parent widget
+        * @param x
+        *            the X position
+        * @param y
+        *            the Y position
+        * @param width
+        *            the width of the {@link TTable}
+        * @param height
+        *            the height of the {@link TTable}
+        * @param enterAction
+        *            an action to call when a cell is selected
+        * @param moveAction
+        *            an action to call when the currently active cell is changed
+        * @param headers
+        *            the headers of the {@link TTable}
+        * @param showHeaders
+        *            TRUE to show the headers on screen
+        */
+       public TTable(TWidget parent, int x, int y, int width, int height,
+                       final TAction enterAction, final TAction moveAction,
+                       List<? extends Object> headers, boolean showHeaders) {
+               super(parent, x, y, width, height);
+
+               this.model = new TTableModel(new Object[][] {});
+               setSelectedRow(-1);
+               this.selectedColumn = -1;
+
+               setHeaders(headers, showHeaders);
+
+               this.enterAction = enterAction;
+               this.moveAction = moveAction;
+
+               reflowData();
+       }
+
+       /**
+        * The data model (containing the actual data) used by this {@link TTable},
+        * as with the usual Swing tables.
+        * 
+        * @return the model
+        */
+       public TableModel getModel() {
+               return model;
+       }
+
+       /**
+        * The data model (containing the actual data) used by this {@link TTable},
+        * as with the usual Swing tables.
+        * <p>
+        * Will reset all the rendering cells.
+        * 
+        * @param model
+        *            the new model
+        */
+       public void setModel(TableModel model) {
+               this.model = model;
+               reflowData();
+       }
+
+       /**
+        * The columns used by this {@link TTable} (you need to access them if you
+        * want to change the way they are rendered, for instance, or their size).
+        * 
+        * @return the columns
+        */
+       public List<TTableColumn> getColumns() {
+               return columns;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the separators (one separator
+        * between two data columns).
+        * 
+        * @return the renderer, or the default one if none is set (never NULL)
+        */
+       public TTableCellRenderer getSeparatorRenderer() {
+               return separatorRenderer != null ? separatorRenderer
+                               : defaultSeparatorRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the separators (one separator
+        * between two data columns).
+        * 
+        * @param separatorRenderer
+        *            the new renderer, or NULL to use the default renderer
+        */
+       public void setSeparatorRenderer(TTableCellRenderer separatorRenderer) {
+               this.separatorRenderer = separatorRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the headers (if
+        * {@link TTable#isShowHeader()} is enabled, the first line represents the
+        * headers with the column names).
+        * 
+        * @return the renderer, or the default one if none is set (never NULL)
+        */
+       public TTableCellRenderer getHeaderRenderer() {
+               return headerRenderer != null ? headerRenderer : defaultHeaderRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} used by the headers (if
+        * {@link TTable#isShowHeader()} is enabled, the first line represents the
+        * headers with the column names).
+        * 
+        * @param headerRenderer
+        *            the new renderer, or NULL to use the default renderer
+        */
+       public void setHeaderRenderer(TTableCellRenderer headerRenderer) {
+               this.headerRenderer = headerRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} to use on separators in header lines (see
+        * the related methods to understand what each of them is).
+        * 
+        * @return the renderer, or the default one if none is set (never NULL)
+        */
+       public TTableCellRenderer getHeaderSeparatorRenderer() {
+               return headerSeparatorRenderer != null ? headerSeparatorRenderer
+                               : defaultHeaderSeparatorRenderer;
+       }
+
+       /**
+        * The {@link TTableCellRenderer} to use on separators in header lines (see
+        * the related methods to understand what each of them is).
+        * 
+        * @param headerSeparatorRenderer
+        *            the new renderer, or NULL to use the default renderer
+        */
+       public void setHeaderSeparatorRenderer(
+                       TTableCellRenderer headerSeparatorRenderer) {
+               this.headerSeparatorRenderer = headerSeparatorRenderer;
+       }
+
+       /**
+        * Show the header row on this {@link TTable}.
+        * 
+        * @return TRUE if we show them
+        */
+       public boolean isShowHeader() {
+               return showHeader;
+       }
+
+       /**
+        * Show the header row on this {@link TTable}.
+        * 
+        * @param showHeader
+        *            TRUE to show them
+        */
+       public void setShowHeader(boolean showHeader) {
+               this.showHeader = showHeader;
+               setYOffset(showHeader ? 2 : 0);
+               reflowData();
+       }
+
+       /**
+        * Change the headers of the table.
+        * <p>
+        * Note that this method is a convenience method that will create columns of
+        * the corresponding names and set them. As such, the previous columns if
+        * any will be replaced.
+        * 
+        * @param headers
+        *            the new headers
+        */
+       public void setHeaders(List<? extends Object> headers) {
+               setHeaders(headers, showHeader);
+       }
+
+       /**
+        * Change the headers of the table.
+        * <p>
+        * Note that this method is a convenience method that will create columns of
+        * the corresponding names and set them in the same order. As such, the
+        * previous columns if any will be replaced.
+        * 
+        * @param headers
+        *            the new headers
+        * @param showHeader
+        *            TRUE to show them on screen
+        */
+       public void setHeaders(List<? extends Object> headers, boolean showHeader) {
+               if (headers == null) {
+                       headers = new ArrayList<Object>();
+               }
+
+               int i = 0;
+               this.columns = new ArrayList<TTableColumn>();
+               for (Object header : headers) {
+                       this.columns.add(new TTableColumn(i++, header, getModel()));
+               }
+
+               setShowHeader(showHeader);
+       }
+
+       /**
+        * Set the data and create a new {@link TTableModel} for them.
+        * 
+        * @param data
+        *            the data to set into this table, as an array of rows, that is,
+        *            an array of arrays of values
+        */
+
+       public void setRowData(Object[][] data) {
+               setRowData(TTableModel.convert(data));
+       }
+
+       /**
+        * Set the data and create a new {@link TTableModel} for them.
+        * 
+        * @param data
+        *            the data to set into this table, as a collection of rows, that
+        *            is, a collection of collections of values
+        */
+       public void setRowData(
+                       final Collection<? extends Collection<? extends Object>> data) {
+               setModel(new TTableModel(data));
+       }
+
+       /**
+        * The currently selected cell.
+        * 
+        * @return the cell
+        */
+       public Object getSelectedCell() {
+               int selectedRow = getSelectedRow();
+               if (selectedRow >= 0 && selectedColumn >= 0) {
+                       return model.getValueAt(selectedRow, selectedColumn);
+               }
+
+               return null;
+       }
+
+       @Override
+       public int getRowCount() {
+               if (model == null) {
+                       return 0;
+               }
+               return model.getRowCount();
+       }
+
+       @Override
+       public int getColumnCount() {
+               if (model == null) {
+                       return 0;
+               }
+               return model.getColumnCount();
+       }
+
+       @Override
+       public void dispatchEnter(int selectedRow) {
+               super.dispatchEnter(selectedRow);
+               if (enterAction != null) {
+                       enterAction.DO();
+               }
+       }
+
+       @Override
+       public void dispatchMove(int fromRow, int toRow) {
+               super.dispatchMove(fromRow, toRow);
+               if (moveAction != null) {
+                       moveAction.DO();
+               }
+       }
+
+       /**
+        * Clear the content of the {@link TTable}.
+        * <p>
+        * It will not affect the headers.
+        * <p>
+        * You may want to call {@link TTable#reflowData()} when done to see the
+        * changes.
+        */
+       public void clear() {
+               setSelectedRow(-1);
+               selectedColumn = -1;
+               setModel(new TTableModel(new Object[][] {}));
+       }
+
+       @Override
+       public void reflowData() {
+               super.reflowData();
+
+               int lastAutoColumn = -1;
+               int rowWidth = 0;
+
+               int i = 0;
+               for (TTableColumn tcol : columns) {
+                       tcol.reflowData();
+
+                       if (!tcol.isForcedWidth()) {
+                               lastAutoColumn = i;
+                       }
+
+                       rowWidth += tcol.getWidth();
+
+                       i++;
+               }
+
+               if (!columns.isEmpty()) {
+                       rowWidth += (i - 1) * getSeparatorRenderer().getWidthOf(null);
+
+                       int extraWidth = getWidth() - rowWidth;
+                       if (extraWidth > 0) {
+                               if (lastAutoColumn < 0) {
+                                       lastAutoColumn = columns.size() - 1;
+                               }
+                               TTableColumn tcol = columns.get(lastAutoColumn);
+                               tcol.expandWidthTo(tcol.getWidth() + extraWidth);
+                               rowWidth += extraWidth;
+                       }
+               }
+       }
+
+       @Override
+       public void draw() {
+               int begin = vScroller.getValue();
+               int y = this.showHeader ? 2 : 0;
+
+               if (showHeader) {
+                       CellAttributes colorHeaders = getHeaderRenderer()
+                                       .getCellAttributes(getTheme(), false, isAbsoluteActive());
+                       drawRow(-1, 0);
+                       String formatString = "%-" + Integer.toString(getWidth()) + "s";
+                       String data = String.format(formatString, "");
+                       getScreen().putStringXY(0, 1, data, colorHeaders);
+               }
+
+               // draw the actual rows until no more,
+               // then pad the rest with blank rows
+               for (int i = begin; i < getRowCount(); i++) {
+                       drawRow(i, y);
+                       y++;
+
+                       // -2: window borders
+                       if (y >= getHeight() - 2 - getHorizontalScroller().getHeight()) {
+                               break;
+                       }
+               }
+
+               CellAttributes emptyRowColor = getSeparatorRenderer()
+                               .getCellAttributes(getTheme(), false, isAbsoluteActive());
+               for (int i = getRowCount(); i < getHeight(); i++) {
+                       getScreen().hLineXY(0, y, getWidth() - 1, ' ', emptyRowColor);
+                       y++;
+               }
+       }
+
+       @Override
+       protected int getVirtualWidth() {
+               int width = 0;
+
+               if (getColumns() != null) {
+                       for (TTableColumn tcol : getColumns()) {
+                               width += tcol.getWidth();
+                       }
+
+                       if (getColumnCount() > 0) {
+                               width += (getColumnCount() - 1)
+                                               * getSeparatorRenderer().getWidthOf(null);
+                       }
+               }
+
+               return width;
+       }
+
+       @Override
+       protected int getVirtualHeight() {
+               // TODO: allow changing the height of one row
+               return (showHeader ? 2 : 0) + (getRowCount() * 1);
+       }
+
+       /**
+        * Draw the given row (it <b>MUST</b> exist) at the specified index and
+        * offset.
+        * 
+        * @param rowIndex
+        *            the index of the row to draw or -1 for the headers
+        * @param y
+        *            the Y position
+        */
+       private void drawRow(int rowIndex, int y) {
+               for (int i = 0; i < getColumnCount(); i++) {
+                       TTableColumn tcol = columns.get(i);
+                       Object value;
+                       if (rowIndex < 0) {
+                               value = tcol.getHeaderValue();
+                       } else {
+                               value = model.getValueAt(rowIndex, tcol.getModelIndex());
+                       }
+
+                       if (i > 0) {
+                               TTableCellRenderer sep = rowIndex < 0 ? getHeaderSeparatorRenderer()
+                                               : getSeparatorRenderer();
+                               sep.renderTableCell(this, null, rowIndex, i - 1, y);
+                       }
+
+                       if (rowIndex < 0) {
+                               getHeaderRenderer()
+                                               .renderTableCell(this, value, rowIndex, i, y);
+                       } else {
+                               tcol.getRenderer().renderTableCell(this, value, rowIndex, i, y);
+                       }
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableCellRenderer.java b/src/be/nikiroo/jexer/TTableCellRenderer.java
new file mode 100644 (file)
index 0000000..6d7b3b3
--- /dev/null
@@ -0,0 +1,240 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.ColorTheme;
+
+/**
+ * A {@link TTable} cell renderer allows you to customize the way a single cell
+ * will be displayed on screen.
+ * <p>
+ * It can be used in a {@link TTable} for the haeders or the separators or in a
+ * {@link TTableColumn} for the data.
+ * 
+ * @author niki
+ */
+abstract public class TTableCellRenderer {
+       private CellRendererMode mode;
+
+       /**
+        * The simple renderer mode.
+        * 
+        * @author niki
+        */
+       public enum CellRendererMode {
+               /** Normal text mode */
+               NORMAL,
+               /** Only display a separator */
+               SEPARATOR,
+               /** Header text mode */
+               HEADER,
+               /** Both HEADER and SEPARATOR at once */
+               HEADER_SEPARATOR;
+
+               /**
+                * This mode represents a separator.
+                * 
+                * @return TRUE for separators
+                */
+               public boolean isSeparator() {
+                       return this == SEPARATOR || this == HEADER_SEPARATOR;
+               }
+
+               /**
+                * This mode represents a header.
+                * 
+                * @return TRUE for headers
+                */
+               public boolean isHeader() {
+                       return this == HEADER || this == HEADER_SEPARATOR;
+               }
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode, cannot be NULL
+        */
+       public TTableCellRenderer(CellRendererMode mode) {
+               if (mode == null) {
+                       throw new IllegalArgumentException(
+                                       "Cannot create a renderer of type NULL");
+               }
+
+               this.mode = mode;
+       }
+
+       /**
+        * Render the given value.
+        * 
+        * @param table
+        *            the table to write on
+        * @param value
+        *            the value to write
+        * @param rowIndex
+        *            the row index in the table
+        * @param colIndex
+        *            the column index in the table
+        * @param y
+        *            the Y position at which to draw this row
+        */
+       abstract public void renderTableCell(TTable table, Object value,
+                       int rowIndex, int colIndex, int y);
+
+       /**
+        * The mode of this {@link TTableCellRenderer}.
+        * 
+        * @return the mode
+        */
+       public CellRendererMode getMode() {
+               return mode;
+       }
+
+       /**
+        * The cell attributes to use for the given state.
+        * 
+        * @param theme
+        *            the color theme to use
+        * @param isSelected
+        *            TRUE if the cell is selected
+        * @param hasFocus
+        *            TRUE if the cell has focus
+        * 
+        * @return the attributes
+        */
+       public CellAttributes getCellAttributes(ColorTheme theme,
+                       boolean isSelected, boolean hasFocus) {
+               return theme.getColor(getColorKey(isSelected, hasFocus));
+       }
+
+       /**
+        * Measure the width of the value.
+        * 
+        * @param value
+        *            the value to measure
+        * 
+        * @return its width
+        */
+       public int getWidthOf(Object value) {
+               if (getMode().isSeparator()) {
+                       return asText(null, 0, false).length();
+               }
+               return ("" + value).length();
+       }
+
+       /**
+        * The colour to use for the given state, specified as a Jexer colour key.
+        * 
+        * @param isSelected
+        *            TRUE if the cell is selected
+        * @param hasFocus
+        *            TRUE if the cell has focus
+        * 
+        * @return the colour key
+        */
+       protected String getColorKey(boolean isSelected, boolean hasFocus) {
+               if (mode.isHeader()) {
+                       return "tlabel";
+               }
+
+               String colorKey = "tlist";
+               if (isSelected) {
+                       colorKey += ".selected";
+               } else if (!hasFocus) {
+                       colorKey += ".inactive";
+               }
+
+               return colorKey;
+       }
+
+       /**
+        * Return the X offset to use to draw a column at the given index.
+        * 
+        * @param table
+        *            the table to draw into
+        * @param colIndex
+        *            the column index
+        * 
+        * @return the offset
+        */
+       protected int getXOffset(TTable table, int colIndex) {
+               int xOffset = -table.getHorizontalValue();
+               for (int i = 0; i <= colIndex; i++) {
+                       TTableColumn tcol = table.getColumns().get(i);
+                       xOffset += tcol.getWidth();
+                       if (i > 0) {
+                               xOffset += table.getSeparatorRenderer().getWidthOf(null);
+                       }
+               }
+
+               TTableColumn tcol = table.getColumns().get(colIndex);
+               if (!getMode().isSeparator()) {
+                       xOffset -= tcol.getWidth();
+               }
+
+               return xOffset;
+       }
+
+       /**
+        * Return the text to use (usually the converted-to-text value, except for
+        * the special separator mode).
+        * 
+        * @param value
+        *            the value to get the text of
+        * @param width
+        *            the width we should tale
+        * @param align
+        *            the text to the right
+        * 
+        * @return the {@link String} to display
+        */
+       protected String asText(Object value, int width, boolean rightAlign) {
+               if (getMode().isSeparator()) {
+                       // some nice characters for the separator: ┃ │ |
+                       return " │ ";
+               }
+
+               if (width <= 0) {
+                       return "";
+               }
+
+               String format;
+               if (!rightAlign) {
+                       // Left align
+                       format = "%-" + width + "s";
+               } else {
+                       // right align
+                       format = "%" + width + "s";
+               }
+
+               return String.format(format, value);
+       }
+}
\ No newline at end of file
diff --git a/src/be/nikiroo/jexer/TTableCellRendererText.java b/src/be/nikiroo/jexer/TTableCellRendererText.java
new file mode 100644 (file)
index 0000000..8f81883
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import jexer.bits.CellAttributes;
+
+/**
+ * A simple {@link TTableCellRenderer} that display the values within a
+ * {@link TLabel}.
+ * <p>
+ * It supports a few different modes, see
+ * {@link TTableOldSimpleTextCellRenderer.CellRendererMode}.
+ * 
+ * @author niki
+ */
+public class TTableCellRendererText extends TTableCellRenderer {
+       private boolean rightAlign;
+
+       /**
+        * Create a new renderer for normal text mode.
+        */
+       public TTableCellRendererText() {
+               this(CellRendererMode.NORMAL);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode
+        */
+       public TTableCellRendererText(CellRendererMode mode) {
+               this(mode, false);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode, cannot be NULL
+        */
+       public TTableCellRendererText(CellRendererMode mode,
+                       boolean rightAlign) {
+               super(mode);
+
+               this.rightAlign = rightAlign;
+       }
+
+       @Override
+       public void renderTableCell(TTable table, Object value, int rowIndex,
+                       int colIndex, int y) {
+
+               int xOffset = getXOffset(table, colIndex);
+               TTableColumn tcol = table.getColumns().get(colIndex);
+               String data = asText(value, tcol.getWidth(), rightAlign);
+
+               if (!data.isEmpty()) {
+                       boolean isSelected = table.getSelectedRow() == rowIndex;
+                       boolean hasFocus = table.isAbsoluteActive();
+                       CellAttributes color = getCellAttributes(table.getWindow()
+                                       .getApplication().getTheme(), isSelected, hasFocus);
+                       table.getScreen().putStringXY(xOffset, y, data, color);
+               }
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableCellRendererWidget.java b/src/be/nikiroo/jexer/TTableCellRendererWidget.java
new file mode 100644 (file)
index 0000000..22c6f47
--- /dev/null
@@ -0,0 +1,170 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jexer.TLabel;
+import jexer.TWidget;
+
+/**
+ * A simple {@link TTableCellRenderer} that display the values within a
+ * {@link TLabel}.
+ * <p>
+ * It supports a few different modes, see
+ * {@link TTableSimpleTextCellRenderer.CellRendererMode}.
+ * 
+ * @author niki
+ */
+public class TTableCellRendererWidget extends TTableCellRenderer {
+       private boolean rightAlign;
+       private Map<String, TWidget> widgets = new HashMap<String, TWidget>();
+
+       /**
+        * Create a new renderer for normal text mode.
+        */
+       public TTableCellRendererWidget() {
+               this(CellRendererMode.NORMAL);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode
+        */
+       public TTableCellRendererWidget(CellRendererMode mode) {
+               this(mode, false);
+       }
+
+       /**
+        * Create a new renderer of the given mode.
+        * 
+        * @param mode
+        *            the renderer mode, cannot be NULL
+        */
+       public TTableCellRendererWidget(CellRendererMode mode, boolean rightAlign) {
+               super(mode);
+
+               this.rightAlign = rightAlign;
+       }
+
+       @Override
+       public void renderTableCell(TTable table, Object value, int rowIndex,
+                       int colIndex, int y) {
+
+               String wkey = "[Row " + y + " " + getMode() + "]";
+               TWidget widget = widgets.get(wkey);
+
+               TTableColumn tcol = table.getColumns().get(colIndex);
+               boolean isSelected = table.getSelectedRow() == rowIndex;
+               boolean hasFocus = table.isAbsoluteActive();
+               int width = tcol.getWidth();
+
+               int xOffset = getXOffset(table, colIndex);
+
+               if (widget != null
+                               && !updateTableCellRendererComponent(widget, value, isSelected,
+                                               hasFocus, y, xOffset, width)) {
+                       table.removeChild(widget);
+                       widget = null;
+               }
+
+               if (widget == null) {
+                       widget = getTableCellRendererComponent(table, value, isSelected,
+                                       hasFocus, y, xOffset, width);
+               }
+
+               widgets.put(wkey, widget);
+       }
+
+       /**
+        * Create a new {@link TWidget} to represent the given value.
+        * 
+        * @param table
+        *            the parent {@link TTable}
+        * @param value
+        *            the value to represent
+        * @param isSelected
+        *            TRUE if selected
+        * @param hasFocus
+        *            TRUE if focused
+        * @param row
+        *            the row to draw it at
+        * @param column
+        *            the column to draw it at
+        * @param width
+        *            the width of the control
+        * 
+        * @return the widget
+        */
+       protected TWidget getTableCellRendererComponent(TTable table, Object value,
+                       boolean isSelected, boolean hasFocus, int row, int column, int width) {
+               return new TLabel(table, asText(value, width, rightAlign), column, row,
+                               getColorKey(isSelected, hasFocus), false);
+       }
+
+       /**
+        * Update the content of the widget if at all possible.
+        * 
+        * @param component
+        *            the component to update
+        * @param value
+        *            the value to represent
+        * @param isSelected
+        *            TRUE if selected
+        * @param hasFocus
+        *            TRUE if focused
+        * @param row
+        *            the row to draw it at
+        * @param column
+        *            the column to draw it at
+        * @param width
+        *            the width of the control
+        * 
+        * @return TRUE if the operation was possible, FALSE if it failed
+        */
+       protected boolean updateTableCellRendererComponent(TWidget component,
+                       Object value, boolean isSelected, boolean hasFocus, int row,
+                       int column, int width) {
+
+               if (component instanceof TLabel) {
+                       TLabel widget = (TLabel) component;
+                       widget.setLabel(asText(value, width, rightAlign));
+                       widget.setColorKey(getColorKey(isSelected, hasFocus));
+                       widget.setWidth(width);
+                       widget.setX(column);
+                       widget.setY(row);
+                       return true;
+               }
+
+               return false;
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableColumn.java b/src/be/nikiroo/jexer/TTableColumn.java
new file mode 100644 (file)
index 0000000..3eea230
--- /dev/null
@@ -0,0 +1,129 @@
+package be.nikiroo.jexer;
+
+import javax.swing.table.TableModel;
+
+import be.nikiroo.jexer.TTableCellRenderer.CellRendererMode;
+
+public class TTableColumn {
+       static private TTableCellRenderer defaultrenderer = new TTableCellRendererText(
+                       CellRendererMode.NORMAL);
+
+       private TableModel model;
+       private int modelIndex;
+       private int width;
+       private boolean forcedWidth;
+
+       private TTableCellRenderer renderer;
+
+       /** The auto-computed width of the column (the width of the largest value) */
+       private int autoWidth;
+
+       private Object headerValue;
+
+       public TTableColumn(int modelIndex) {
+               this(modelIndex, null);
+       }
+
+       public TTableColumn(int modelIndex, String colName) {
+               this(modelIndex, colName, null);
+       }
+
+       // set the width and preferred with the the max data size
+       public TTableColumn(int modelIndex, Object colValue, TableModel model) {
+               this.model = model;
+               this.modelIndex = modelIndex;
+
+               reflowData();
+
+               if (colValue != null) {
+                       setHeaderValue(colValue);
+               }
+       }
+
+       // never null
+       public TTableCellRenderer getRenderer() {
+               return renderer != null ? renderer : defaultrenderer;
+       }
+
+       public void setCellRenderer(TTableCellRenderer renderer) {
+               this.renderer = renderer;
+       }
+
+       /**
+        * Recompute whatever data is displayed by this widget.
+        * <p>
+        * Will just update the sizes in this case.
+        */
+       public void reflowData() {
+               if (model != null) {
+                       int maxDataSize = 0;
+                       for (int i = 0; i < model.getRowCount(); i++) {
+                               maxDataSize = Math.max(
+                                               maxDataSize,
+                                               getRenderer().getWidthOf(
+                                                               model.getValueAt(i, modelIndex)));
+                       }
+
+                       autoWidth = maxDataSize;
+                       if (!forcedWidth) {
+                               setWidth(maxDataSize);
+                       }
+               } else {
+                       autoWidth = 0;
+                       forcedWidth = false;
+                       width = 0;
+               }
+       }
+
+       public int getModelIndex() {
+               return modelIndex;
+       }
+
+       /**
+        * The actual size of the column. This can be auto-computed in some cases.
+        * 
+        * @return the width (never &lt; 0)
+        */
+       public int getWidth() {
+               return width;
+       }
+
+       /**
+        * Set the actual size of the column or -1 for auto size.
+        * 
+        * @param width
+        *            the width (or -1 for auto)
+        */
+       public void setWidth(int width) {
+               forcedWidth = width >= 0;
+
+               if (forcedWidth) {
+                       this.width = width;
+               } else {
+                       this.width = autoWidth;
+               }
+       }
+
+       /**
+        * The width was forced by the user (using
+        * {@link TTableColumn#setWidth(int)} with a positive value).
+        * 
+        * @return TRUE if it was
+        */
+       public boolean isForcedWidth() {
+               return forcedWidth;
+       }
+
+       // not an actual forced width, but does change the width return
+       void expandWidthTo(int width) {
+               this.width = width;
+       }
+
+       public Object getHeaderValue() {
+               return headerValue;
+       }
+
+       public void setHeaderValue(Object headerValue) {
+               this.headerValue = headerValue;
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableLine.java b/src/be/nikiroo/jexer/TTableLine.java
new file mode 100644 (file)
index 0000000..f393621
--- /dev/null
@@ -0,0 +1,135 @@
+package be.nikiroo.jexer;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+
+public class TTableLine implements List<String> {
+       //TODO: in TTable: default to header of size 1
+       private List<String> list;
+
+       public TTableLine(List<String> list) {
+               this.list = list;
+       }
+
+       // TODO: override this and the rest shall follow
+       protected List<String> getList() {
+               return list;
+       }
+
+       @Override
+       public int size() {
+               return getList().size();
+       }
+
+       @Override
+       public boolean isEmpty() {
+               return getList().isEmpty();
+       }
+
+       @Override
+       public boolean contains(Object o) {
+               return getList().contains(o);
+       }
+
+       @Override
+       public Iterator<String> iterator() {
+               return getList().iterator();
+       }
+
+       @Override
+       public Object[] toArray() {
+               return getList().toArray();
+       }
+
+       @Override
+       public <T> T[] toArray(T[] a) {
+               return getList().toArray(a);
+       }
+
+       @Override
+       public boolean containsAll(Collection<?> c) {
+               return getList().containsAll(c);
+       }
+
+       @Override
+       public String get(int index) {
+               return getList().get(index);
+       }
+
+       @Override
+       public int indexOf(Object o) {
+               return getList().indexOf(o);
+       }
+
+       @Override
+       public int lastIndexOf(Object o) {
+               return getList().lastIndexOf(o);
+       }
+
+       @Override
+       public List<String> subList(int fromIndex, int toIndex) {
+               return getList().subList(fromIndex, toIndex);
+       }
+
+       @Override
+       public ListIterator<String> listIterator() {
+               return getList().listIterator();
+       }
+
+       @Override
+       public ListIterator<String> listIterator(int index) {
+               return getList().listIterator(index);
+       }
+
+       @Override
+       public boolean add(String e) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean remove(Object o) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean addAll(Collection<? extends String> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean addAll(int index, Collection<? extends String> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean removeAll(Collection<?> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public boolean retainAll(Collection<?> c) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public void clear() {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public String set(int index, String element) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public void add(int index, String element) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+
+       @Override
+       public String remove(int index) {
+               throw new UnsupportedOperationException("Read-only collection");
+       }
+}
diff --git a/src/be/nikiroo/jexer/TTableModel.java b/src/be/nikiroo/jexer/TTableModel.java
new file mode 100644 (file)
index 0000000..cd86d35
--- /dev/null
@@ -0,0 +1,176 @@
+/*
+ * Jexer - Java Text User Interface
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (C) 2019 David "Niki" ROULET
+ *
+ * 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 David ROULET [niki@nikiroo.be]
+ * @version 1
+ */
+package be.nikiroo.jexer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+
+import javax.swing.event.TableModelListener;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.table.TableModel;
+
+/**
+ * The model of a {@link TTable}. It contains the data of the table and allows
+ * you access to it.
+ * <p>
+ * Note that you don't need to send it the representation of the data, but the
+ * data itself; {@link TTableCellRenderer} is the class responsible of
+ * representing that data (you can change the headers renderer on a
+ * {@link TTable} and the cells renderer on each of its {@link TTableColumn}).
+ * <p>
+ * It works in a similar way to the Java Swing version of it.
+ * 
+ * @author niki
+ */
+public class TTableModel implements TableModel {
+       private TableModel model;
+
+       /**
+        * Create a new {@link TTableModel} with the given data inside.
+        * 
+        * @param data
+        *            the data
+        */
+       public TTableModel(Object[][] data) {
+               this(convert(data));
+       }
+
+       /**
+        * Create a new {@link TTableModel} with the given data inside.
+        * 
+        * @param data
+        *            the data
+        */
+       public TTableModel(
+                       final Collection<? extends Collection<? extends Object>> data) {
+
+               int maxItemsPerRow = 0;
+               for (Collection<? extends Object> rowOfData : data) {
+                       maxItemsPerRow = Math.max(maxItemsPerRow, rowOfData.size());
+               }
+
+               int i = 0;
+               final Object[][] odata = new Object[data.size()][maxItemsPerRow];
+               for (Collection<? extends Object> rowOfData : data) {
+                       odata[i] = new String[maxItemsPerRow];
+                       int j = 0;
+                       for (Object pieceOfData : rowOfData) {
+                               odata[i][j] = pieceOfData;
+                               j++;
+                       }
+                       i++;
+               }
+
+               final int maxItemsPerRowFinal = maxItemsPerRow;
+               this.model = new AbstractTableModel() {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public Object getValueAt(int rowIndex, int columnIndex) {
+                               return odata[rowIndex][columnIndex];
+                       }
+
+                       @Override
+                       public int getRowCount() {
+                               return odata.length;
+                       }
+
+                       @Override
+                       public int getColumnCount() {
+                               return maxItemsPerRowFinal;
+                       }
+               };
+       }
+
+       @Override
+       public int getRowCount() {
+               return model.getRowCount();
+       }
+
+       @Override
+       public int getColumnCount() {
+               return model.getColumnCount();
+       }
+
+       @Override
+       public String getColumnName(int columnIndex) {
+               return model.getColumnName(columnIndex);
+       }
+
+       @Override
+       public Class<?> getColumnClass(int columnIndex) {
+               return model.getColumnClass(columnIndex);
+       }
+
+       @Override
+       public boolean isCellEditable(int rowIndex, int columnIndex) {
+               return model.isCellEditable(rowIndex, columnIndex);
+       }
+
+       @Override
+       public Object getValueAt(int rowIndex, int columnIndex) {
+               return model.getValueAt(rowIndex, columnIndex);
+       }
+
+       @Override
+       public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
+               model.setValueAt(aValue, rowIndex, columnIndex);
+       }
+
+       @Override
+       public void addTableModelListener(TableModelListener l) {
+               model.addTableModelListener(l);
+       }
+
+       @Override
+       public void removeTableModelListener(TableModelListener l) {
+               model.removeTableModelListener(l);
+       }
+
+       /**
+        * Helper method to convert an array to a collection.
+        * 
+        * @param <T>
+        * 
+        * @param data
+        *            the data
+        * 
+        * @return the data in another format
+        */
+       static <T> Collection<Collection<T>> convert(T[][] data) {
+               Collection<Collection<T>> dataCollection = new ArrayList<Collection<T>>(
+                               data.length);
+               for (T pieceOfData[] : data) {
+                       dataCollection.add(Arrays.asList(pieceOfData));
+               }
+
+               return dataCollection;
+       }
+}
similarity index 100%
rename from Cache.java
rename to src/be/nikiroo/utils/Cache.java
similarity index 100%
rename from Image.java
rename to src/be/nikiroo/utils/Image.java
similarity index 100%
rename from Proxy.java
rename to src/be/nikiroo/utils/Proxy.java
diff --git a/src/jexer/.classpath b/src/jexer/.classpath
new file mode 100644 (file)
index 0000000..9b07da8
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+       <classpathentry kind="src" path="src"/>
+       <classpathentry exported="true" kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
+       <classpathentry kind="output" path="bin"/>
+</classpath>
diff --git a/src/jexer/.gitignore b/src/jexer/.gitignore
new file mode 100644 (file)
index 0000000..30d9f7c
--- /dev/null
@@ -0,0 +1,35 @@
+*.class
+bin/**
+build/**
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.ear
+
+# Generated docs
+docs/**
+
+# Maven artifacts
+target/**
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+# Editor backup files
+*.java~
+*.xml~
+
+# Scratch space
+misc/**
+/.project~
+
+pmd.bash
+pmd-results.html
+examples/*.sh
+
+# Fonts for testing
+fonts/**
diff --git a/src/jexer/.project b/src/jexer/.project
new file mode 100644 (file)
index 0000000..c0afd85
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+       <name>jexer</name>
+       <comment></comment>
+       <projects>
+       </projects>
+       <buildSpec>
+               <buildCommand>
+                       <name>org.eclipse.jdt.core.javabuilder</name>
+                       <arguments>
+                       </arguments>
+               </buildCommand>
+       </buildSpec>
+       <natures>
+               <nature>org.eclipse.jdt.core.javanature</nature>
+       </natures>
+</projectDescription>
diff --git a/src/jexer/EditMenuUser.java b/src/jexer/EditMenuUser.java
new file mode 100644 (file)
index 0000000..52dc33e
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * 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;
+
+/**
+ * EditMenuUser is used by TApplication to enable/disable edit menu items.  A
+ * widget that supports these functions should define an onCommand method
+ * that operates on cmCut, cmCopy, cmPaste, and cmClear.
+ */
+public interface EditMenuUser {
+
+    /**
+     * Check if the cut menu item should be enabled.
+     *
+     * @return true if the cut menu item should be enabled
+     */
+    public boolean isEditMenuCut();
+
+    /**
+     * Check if the copy menu item should be enabled.
+     *
+     * @return true if the copy menu item should be enabled
+     */
+    public boolean isEditMenuCopy();
+
+    /**
+     * Check if the paste menu item should be enabled.
+     *
+     * @return true if the paste menu item should be enabled
+     */
+    public boolean isEditMenuPaste();
+
+    /**
+     * Check if the clear menu item should be enabled.
+     *
+     * @return true if the clear menu item should be enabled
+     */
+    public boolean isEditMenuClear();
+
+}
diff --git a/src/jexer/Scrollable.java b/src/jexer/Scrollable.java
new file mode 100644 (file)
index 0000000..b844ca6
--- /dev/null
@@ -0,0 +1,280 @@
+/*
+ * 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;
+
+/**
+ * Scrollable provides a public API for horizontal and vertical scrollbars.
+ * Note that not all Scrollables support both horizontal and vertical
+ * scrolling; for those that only support a subset, it is expected that the
+ * methods corresponding to the missing scrollbar quietly succeed without
+ * throwing any exceptions.
+ */
+public interface Scrollable {
+
+    /**
+     * Get the horizontal scrollbar, or null if this Viewport does not
+     * support horizontal scrolling.
+     *
+     * @return the horizontal scrollbar
+     */
+    public THScroller getHorizontalScroller();
+
+    /**
+     * Get the vertical scrollbar, or null if this Viewport does not support
+     * vertical scrolling.
+     *
+     * @return the vertical scrollbar
+     */
+    public TVScroller getVerticalScroller();
+
+    /**
+     * Get the value that corresponds to being on the top edge of the
+     * vertical scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getTopValue();
+
+    /**
+     * Set the value that corresponds to being on the top edge of the
+     * vertical scroll bar.
+     *
+     * @param topValue the new scroll value
+     */
+    public void setTopValue(final int topValue);
+
+    /**
+     * Get the value that corresponds to being on the bottom edge of the
+     * vertical scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getBottomValue();
+
+    /**
+     * Set the value that corresponds to being on the bottom edge of the
+     * vertical scroll bar.
+     *
+     * @param bottomValue the new scroll value
+     */
+    public void setBottomValue(final int bottomValue);
+
+    /**
+     * Get current value of the vertical scroll.
+     *
+     * @return the scroll value
+     */
+    public int getVerticalValue();
+
+    /**
+     * Set current value of the vertical scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setVerticalValue(final int value);
+
+    /**
+     * Get the increment for clicking on an arrow on the vertical scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getVerticalSmallChange();
+
+    /**
+     * Set the increment for clicking on an arrow on the vertical scrollbar.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setVerticalSmallChange(final int smallChange);
+
+    /**
+     * Get the increment for clicking in the bar between the box and an
+     * arrow on the vertical scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getVerticalBigChange();
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow on the vertical scrollbar.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setVerticalBigChange(final int bigChange);
+
+    /**
+     * Perform a small step change up.
+     */
+    public void verticalDecrement();
+
+    /**
+     * Perform a small step change down.
+     */
+    public void verticalIncrement();
+
+    /**
+     * Perform a big step change up.
+     */
+    public void bigVerticalDecrement();
+
+    /**
+     * Perform a big step change down.
+     */
+    public void bigVerticalIncrement();
+
+    /**
+     * Go to the top edge of the vertical scroller.
+     */
+    public void toTop();
+
+    /**
+     * Go to the bottom edge of the vertical scroller.
+     */
+    public void toBottom();
+
+    /**
+     * Get the value that corresponds to being on the left edge of the
+     * horizontal scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getLeftValue();
+
+    /**
+     * Set the value that corresponds to being on the left edge of the
+     * horizontal scroll bar.
+     *
+     * @param leftValue the new scroll value
+     */
+    public void setLeftValue(final int leftValue);
+
+    /**
+     * Get the value that corresponds to being on the right edge of the
+     * horizontal scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getRightValue();
+
+    /**
+     * Set the value that corresponds to being on the right edge of the
+     * horizontal scroll bar.
+     *
+     * @param rightValue the new scroll value
+     */
+    public void setRightValue(final int rightValue);
+
+    /**
+     * Get current value of the horizontal scroll.
+     *
+     * @return the scroll value
+     */
+    public int getHorizontalValue();
+
+    /**
+     * Set current value of the horizontal scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setHorizontalValue(final int value);
+
+    /**
+     * Get the increment for clicking on an arrow on the horizontal
+     * scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getHorizontalSmallChange();
+
+    /**
+     * Set the increment for clicking on an arrow on the horizontal
+     * scrollbar.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setHorizontalSmallChange(final int smallChange);
+
+    /**
+     * Get the increment for clicking in the bar between the box and an
+     * arrow on the horizontal scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getHorizontalBigChange();
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow on the horizontal scrollbar.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setHorizontalBigChange(final int bigChange);
+
+    /**
+     * Perform a small step change left.
+     */
+    public void horizontalDecrement();
+
+    /**
+     * Perform a small step change right.
+     */
+    public void horizontalIncrement();
+
+    /**
+     * Perform a big step change left.
+     */
+    public void bigHorizontalDecrement();
+
+    /**
+     * Perform a big step change right.
+     */
+    public void bigHorizontalIncrement();
+
+    /**
+     * Go to the left edge of the horizontal scroller.
+     */
+    public void toLeft();
+
+    /**
+     * Go to the right edge of the horizontal scroller.
+     */
+    public void toRight();
+
+    /**
+     * Go to the top-left edge of the horizontal and vertical scrollers.
+     */
+    public void toHome();
+
+    /**
+     * Go to the bottom-right edge of the horizontal and vertical scrollers.
+     */
+    public void toEnd();
+
+}
diff --git a/src/jexer/TAction.java b/src/jexer/TAction.java
new file mode 100644 (file)
index 0000000..5343143
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * 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;
+
+/**
+ * A TAction represents a simple action to perform in response to the user.
+ *
+ * @see TButton
+ */
+public abstract class TAction {
+
+    /**
+     * The widget that called this action's DO() method.  Note that this
+     * field could be null, for example if executed as a timer action.
+     */
+    public TWidget source;
+
+    /**
+     * An optional bit of data associated with this action.
+     */
+    public Object data;
+
+    /**
+     * Call DO() with source widget set.
+     *
+     * @param source the source widget
+     */
+    public final void DO(final TWidget source) {
+        this.source = source;
+        DO();
+    }
+
+    /**
+     * Call DO() with source widget and data set.
+     *
+     * @param source the source widget
+     * @param data the data
+     */
+    public final void DO(final TWidget source, final Object data) {
+        this.source = source;
+        this.data = data;
+        DO();
+    }
+
+    /**
+     * Various classes will call DO() when they are clicked/selected.
+     */
+    public abstract void DO();
+}
diff --git a/src/jexer/TApplication.java b/src/jexer/TApplication.java
new file mode 100644 (file)
index 0000000..28e3509
--- /dev/null
@@ -0,0 +1,3973 @@
+/*
+ * 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.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.Clipboard;
+import jexer.bits.ColorTheme;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
+import jexer.event.TInputEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.backend.Backend;
+import jexer.backend.MultiBackend;
+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;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TApplication is the main driver class for a full Text User Interface
+ * application.  It manages windows, provides a menu bar and status bar, and
+ * processes events received from the user.
+ */
+public class TApplication implements Runnable {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TApplication.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, emit thread stuff to System.err.
+     */
+    private static final boolean debugThreads = false;
+
+    /**
+     * If true, emit events being processed to System.err.
+     */
+    private static final boolean debugEvents = false;
+
+    /**
+     * If true, do "smart placement" on new windows that are not specified to
+     * be centered.
+     */
+    private static final boolean smartWindowPlacement = true;
+
+    /**
+     * Two backend types are available.
+     */
+    public static enum BackendType {
+        /**
+         * A Swing JFrame.
+         */
+        SWING,
+
+        /**
+         * An ECMA48 / ANSI X3.64 / XTERM style terminal.
+         */
+        ECMA48,
+
+        /**
+         * Synonym for ECMA48.
+         */
+        XTERM
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The primary event handler thread.
+     */
+    private volatile WidgetEventHandler primaryEventHandler;
+
+    /**
+     * The secondary event handler thread.
+     */
+    private volatile WidgetEventHandler secondaryEventHandler;
+
+    /**
+     * The screen handler thread.
+     */
+    private volatile ScreenHandler screenHandler;
+
+    /**
+     * The widget receiving events from the secondary event handler thread.
+     */
+    private volatile TWidget secondaryEventReceiver;
+
+    /**
+     * Access to the physical screen, keyboard, and mouse.
+     */
+    private Backend backend;
+
+    /**
+     * The clipboard for copy and paste.
+     */
+    private Clipboard clipboard = new Clipboard();
+
+    /**
+     * Actual mouse coordinate X.
+     */
+    private int mouseX;
+
+    /**
+     * Actual mouse coordinate Y.
+     */
+    private int mouseY;
+
+    /**
+     * Old drawn version of mouse coordinate X.
+     */
+    private int oldDrawnMouseX;
+
+    /**
+     * Old drawn version mouse coordinate Y.
+     */
+    private int oldDrawnMouseY;
+
+    /**
+     * Old drawn version mouse cell.
+     */
+    private Cell oldDrawnMouseCell = new Cell();
+
+    /**
+     * The last mouse up click time, used to determine if this is a mouse
+     * double-click.
+     */
+    private long lastMouseUpTime;
+
+    /**
+     * The amount of millis between mouse up events to assume a double-click.
+     */
+    private long doubleClickTime = 250;
+
+    /**
+     * Event queue that is filled by run().
+     */
+    private List<TInputEvent> fillEventQueue;
+
+    /**
+     * Event queue that will be drained by either primary or secondary
+     * Thread.
+     */
+    private List<TInputEvent> drainEventQueue;
+
+    /**
+     * Top-level menus in this application.
+     */
+    private List<TMenu> menus;
+
+    /**
+     * Stack of activated sub-menus in this application.
+     */
+    private List<TMenu> subMenus;
+
+    /**
+     * The currently active menu.
+     */
+    private TMenu activeMenu = null;
+
+    /**
+     * Active keyboard accelerators.
+     */
+    private Map<TKeypress, TMenuItem> accelerators;
+
+    /**
+     * All menu items.
+     */
+    private List<TMenuItem> menuItems;
+
+    /**
+     * Windows and widgets pull colors from this ColorTheme.
+     */
+    private ColorTheme theme;
+
+    /**
+     * The top-level windows (but not menus).
+     */
+    private List<TWindow> windows;
+
+    /**
+     * Timers that are being ticked.
+     */
+    private List<TTimer> timers;
+
+    /**
+     * When true, the application has been started.
+     */
+    private volatile boolean started = false;
+
+    /**
+     * When true, exit the application.
+     */
+    private volatile boolean quit = false;
+
+    /**
+     * When true, repaint the entire screen.
+     */
+    private volatile boolean repaint = true;
+
+    /**
+     * Y coordinate of the top edge of the desktop.  For now this is a
+     * constant.  Someday it would be nice to have a multi-line menu or
+     * toolbars.
+     */
+    private int desktopTop = 1;
+
+    /**
+     * Y coordinate of the bottom edge of the desktop.
+     */
+    private int desktopBottom;
+
+    /**
+     * An optional TDesktop background window that is drawn underneath
+     * everything else.
+     */
+    private TDesktop desktop;
+
+    /**
+     * If true, focus follows mouse: windows automatically raised if the
+     * mouse passes over them.
+     */
+    private boolean focusFollowsMouse = false;
+
+    /**
+     * If true, display a text-based mouse cursor.
+     */
+    private boolean textMouse = true;
+
+    /**
+     * If true, hide the mouse after typing a keystroke.
+     */
+    private boolean hideMouseWhenTyping = false;
+
+    /**
+     * If true, the mouse should not be displayed because a keystroke was
+     * typed.
+     */
+    private boolean typingHidMouse = false;
+
+    /**
+     * If true, hide the status bar.
+     */
+    private boolean hideStatusBar = false;
+
+    /**
+     * If true, hide the menu bar.
+     */
+    private boolean hideMenuBar = false;
+
+    /**
+     * The list of commands to run before the next I/O check.
+     */
+    private List<Runnable> invokeLaters = new LinkedList<Runnable>();
+
+    /**
+     * The last time the screen was resized.
+     */
+    private long screenResizeTime = 0;
+
+    /**
+     * If true, screen selection is a rectangle.
+     */
+    private boolean screenSelectionRectangle = false;
+
+    /**
+     * If true, the mouse is dragging a screen selection.
+     */
+    private boolean inScreenSelection = false;
+
+    /**
+     * Screen selection starting X.
+     */
+    private int screenSelectionX0;
+
+    /**
+     * Screen selection starting Y.
+     */
+    private int screenSelectionY0;
+
+    /**
+     * Screen selection ending X.
+     */
+    private int screenSelectionX1;
+
+    /**
+     * Screen selection ending Y.
+     */
+    private int screenSelectionY1;
+
+    /**
+     * The help file data.  Note package private access.
+     */
+    HelpFile helpFile;
+
+    /**
+     * The stack of help topics.  Note package private access.
+     */
+    ArrayList<Topic> helpTopics = new ArrayList<Topic>();
+
+    /**
+     * WidgetEventHandler is the main event consumer loop.  There are at most
+     * two such threads in existence: the primary for normal case and a
+     * secondary that is used for TMessageBox, TInputBox, and similar.
+     */
+    private class WidgetEventHandler implements Runnable {
+        /**
+         * The main application.
+         */
+        private TApplication application;
+
+        /**
+         * Whether or not this WidgetEventHandler is the primary or secondary
+         * thread.
+         */
+        private boolean primary = true;
+
+        /**
+         * Public constructor.
+         *
+         * @param application the main application
+         * @param primary if true, this is the primary event handler thread
+         */
+        public WidgetEventHandler(final TApplication application,
+            final boolean primary) {
+
+            this.application = application;
+            this.primary = primary;
+        }
+
+        /**
+         * The consumer loop.
+         */
+        public void run() {
+            // Wrap everything in a try, so that if we go belly up we can let
+            // the user have their terminal back.
+            try {
+                runImpl();
+            } catch (Throwable t) {
+                this.application.restoreConsole();
+                t.printStackTrace();
+                this.application.exit();
+            }
+        }
+
+        /**
+         * The consumer loop.
+         */
+        private void runImpl() {
+            boolean first = true;
+
+            // Loop forever
+            while (!application.quit) {
+
+                // Wait until application notifies me
+                while (!application.quit) {
+                    try {
+                        synchronized (application.drainEventQueue) {
+                            if (application.drainEventQueue.size() > 0) {
+                                break;
+                            }
+                        }
+
+                        long timeout = 0;
+                        if (first) {
+                            first = false;
+                        } else {
+                            timeout = application.getSleepTime(1000);
+                        }
+
+                        if (timeout == 0) {
+                            // A timer needs to fire, break out.
+                            break;
+                        }
+
+                        if (debugThreads) {
+                            System.err.printf("%d %s %s %s sleep %d millis\n",
+                                System.currentTimeMillis(), this,
+                                primary ? "primary" : "secondary",
+                                Thread.currentThread(), timeout);
+                        }
+
+                        synchronized (this) {
+                            this.wait(timeout);
+                        }
+
+                        if (debugThreads) {
+                            System.err.printf("%d %s %s %s AWAKE\n",
+                                System.currentTimeMillis(), this,
+                                primary ? "primary" : "secondary",
+                                Thread.currentThread());
+                        }
+
+                        if ((!primary)
+                            && (application.secondaryEventReceiver == null)
+                        ) {
+                            // Secondary thread, emergency exit.  If we got
+                            // here then something went wrong with the
+                            // handoff between yield() and closeWindow().
+                            synchronized (application.primaryEventHandler) {
+                                application.primaryEventHandler.notify();
+                            }
+                            application.secondaryEventHandler = null;
+                            throw new RuntimeException("secondary exited " +
+                                "at wrong time");
+                        }
+                        break;
+                    } catch (InterruptedException e) {
+                        // SQUASH
+                    }
+                } // while (!application.quit)
+
+                // Pull all events off the queue
+                for (;;) {
+                    TInputEvent event = null;
+                    synchronized (application.drainEventQueue) {
+                        if (application.drainEventQueue.size() == 0) {
+                            break;
+                        }
+                        event = application.drainEventQueue.remove(0);
+                    }
+
+                    // We will have an event to process, so repaint the
+                    // screen at the end.
+                    application.repaint = true;
+
+                    if (primary) {
+                        primaryHandleEvent(event);
+                    } else {
+                        secondaryHandleEvent(event);
+                    }
+                    if ((!primary)
+                        && (application.secondaryEventReceiver == null)
+                    ) {
+                        // Secondary thread, time to exit.
+
+                        // Eliminate my reference so that wakeEventHandler()
+                        // resumes working on the primary.
+                        application.secondaryEventHandler = null;
+
+                        // We are ready to exit, wake up the primary thread.
+                        // Remember that it is currently sleeping inside its
+                        // primaryHandleEvent().
+                        synchronized (application.primaryEventHandler) {
+                            application.primaryEventHandler.notify();
+                        }
+
+                        // All done!
+                        return;
+                    }
+
+                } // for (;;)
+
+                // Fire timers, update screen.
+                if (!quit) {
+                    application.finishEventProcessing();
+                }
+
+            } // while (true) (main runnable loop)
+        }
+    }
+
+    /**
+     * ScreenHandler pushes screen updates to the physical device.
+     */
+    private class ScreenHandler implements Runnable {
+        /**
+         * The main application.
+         */
+        private TApplication application;
+
+        /**
+         * The dirty flag.
+         */
+        private boolean dirty = false;
+
+        /**
+         * Public constructor.
+         *
+         * @param application the main application
+         */
+        public ScreenHandler(final TApplication application) {
+            this.application = application;
+        }
+
+        /**
+         * The screen update loop.
+         */
+        public void run() {
+            // Wrap everything in a try, so that if we go belly up we can let
+            // the user have their terminal back.
+            try {
+                runImpl();
+            } catch (Throwable t) {
+                this.application.restoreConsole();
+                t.printStackTrace();
+                this.application.exit();
+            }
+        }
+
+        /**
+         * The update loop.
+         */
+        private void runImpl() {
+
+            // Loop forever
+            while (!application.quit) {
+
+                // Wait until application notifies me
+                while (!application.quit) {
+                    try {
+                        synchronized (this) {
+                            if (dirty) {
+                                dirty = false;
+                                break;
+                            }
+
+                            // Always check within 50 milliseconds.
+                            this.wait(50);
+                        }
+                    } catch (InterruptedException e) {
+                        // SQUASH
+                    }
+                } // while (!application.quit)
+
+                 // Flush the screen contents
+                if (debugThreads) {
+                    System.err.printf("%d %s backend.flushScreen()\n",
+                        System.currentTimeMillis(), Thread.currentThread());
+                }
+                synchronized (getScreen()) {
+                    backend.flushScreen();
+                }
+            } // while (true) (main runnable loop)
+
+            // Shutdown the user I/O thread(s)
+            backend.shutdown();
+        }
+
+        /**
+         * Set the dirty flag.
+         */
+        public void setDirty() {
+            synchronized (this) {
+                dirty = true;
+            }
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param backendType BackendType.XTERM, BackendType.ECMA48 or
+     * BackendType.SWING
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public TApplication(final BackendType backendType, final int windowWidth,
+        final int windowHeight, final int fontSize)
+        throws UnsupportedEncodingException {
+
+        switch (backendType) {
+        case SWING:
+            backend = new SwingBackend(this, windowWidth, windowHeight,
+                fontSize);
+            break;
+        case XTERM:
+            // Fall through...
+        case ECMA48:
+            backend = new ECMA48Backend(this, null, null, windowWidth,
+                windowHeight, fontSize);
+            break;
+        default:
+            throw new IllegalArgumentException("Invalid backend type: "
+                + backendType);
+        }
+        TApplicationImpl();
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param backendType BackendType.XTERM, BackendType.ECMA48 or
+     * BackendType.SWING
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public TApplication(final BackendType backendType)
+        throws UnsupportedEncodingException {
+
+        switch (backendType) {
+        case SWING:
+            // The default SwingBackend is 80x25, 20 pt font.  If you want to
+            // change that, you can pass the extra arguments to the
+            // SwingBackend constructor here.  For example, if you wanted
+            // 90x30, 16 pt font:
+            //
+            // backend = new SwingBackend(this, 90, 30, 16);
+            backend = new SwingBackend(this);
+            break;
+        case XTERM:
+            // Fall through...
+        case ECMA48:
+            backend = new ECMA48Backend(this, null, null);
+            break;
+        default:
+            throw new IllegalArgumentException("Invalid backend type: "
+                + backendType);
+        }
+        TApplicationImpl();
+    }
+
+    /**
+     * Public constructor.  The backend type will be BackendType.ECMA48.
+     *
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; shutdown() will (blindly!) put System.in in cooked
+     * mode.  input is always converted to a Reader with UTF-8 encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public TApplication(final InputStream input,
+        final OutputStream output) throws UnsupportedEncodingException {
+
+        backend = new ECMA48Backend(this, input, output);
+        TApplicationImpl();
+    }
+
+    /**
+     * Public constructor.  The backend type will be BackendType.ECMA48.
+     *
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @param setRawMode if true, set System.in into raw mode with stty.
+     * This should in general not be used.  It is here solely for Demo3,
+     * which uses System.in.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public TApplication(final InputStream input, final Reader reader,
+        final PrintWriter writer, final boolean setRawMode) {
+
+        backend = new ECMA48Backend(this, input, reader, writer, setRawMode);
+        TApplicationImpl();
+    }
+
+    /**
+     * Public constructor.  The backend type will be BackendType.ECMA48.
+     *
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public TApplication(final InputStream input, final Reader reader,
+        final PrintWriter writer) {
+
+        this(input, reader, writer, false);
+    }
+
+    /**
+     * Public constructor.  This hook enables use with new non-Jexer
+     * backends.
+     *
+     * @param backend a Backend that is already ready to go.
+     */
+    public TApplication(final Backend backend) {
+        this.backend = backend;
+        backend.setListener(this);
+        TApplicationImpl();
+    }
+
+    /**
+     * Finish construction once the backend is set.
+     */
+    private void TApplicationImpl() {
+        // Text block mouse option
+        if (System.getProperty("jexer.textMouse", "true").equals("false")) {
+            textMouse = false;
+        }
+
+        // Hide mouse when typing option
+        if (System.getProperty("jexer.hideMouseWhenTyping",
+                "false").equals("true")) {
+
+            hideMouseWhenTyping = true;
+        }
+
+        // Hide status bar option
+        if (System.getProperty("jexer.hideStatusBar",
+                "false").equals("true")) {
+            hideStatusBar = true;
+        }
+
+        // Hide menu bar option
+        if (System.getProperty("jexer.hideMenuBar", "false").equals("true")) {
+            hideMenuBar = true;
+        }
+
+        theme           = new ColorTheme();
+        desktopTop      = (hideMenuBar ? 0 : 1);
+        desktopBottom   = getScreen().getHeight() - 1 + (hideStatusBar ? 1 : 0);
+        fillEventQueue  = new LinkedList<TInputEvent>();
+        drainEventQueue = new LinkedList<TInputEvent>();
+        windows         = new LinkedList<TWindow>();
+        menus           = new ArrayList<TMenu>();
+        subMenus        = new ArrayList<TMenu>();
+        timers          = new LinkedList<TTimer>();
+        accelerators    = new HashMap<TKeypress, TMenuItem>();
+        menuItems       = new LinkedList<TMenuItem>();
+        desktop         = new TDesktop(this);
+
+        // Special case: the Swing backend needs to have a timer to drive its
+        // blink state.
+        if ((backend instanceof SwingBackend)
+            || (backend instanceof MultiBackend)
+        ) {
+            // Default to 500 millis, unless a SwingBackend has its own
+            // value.
+            long millis = 500;
+            if (backend instanceof SwingBackend) {
+                millis = ((SwingBackend) backend).getBlinkMillis();
+            }
+            if (millis > 0) {
+                addTimer(millis, true,
+                    new TAction() {
+                        public void DO() {
+                            TApplication.this.doRepaint();
+                        }
+                    }
+                );
+            }
+        }
+
+        // Load the help system
+        invokeLater(new Runnable() {
+            /*
+             * This isn't the best solution.  But basically if a TApplication
+             * subclass constructor throws and needs to use TExceptionDialog,
+             * it may end up at the bottom of the window stack with a bunch
+             * of modal windows on top of it if said constructors spawn their
+             * windows also via invokeLater().  But if they don't do that,
+             * and instead just conventionally construct their windows, then
+             * this exception dialog will end up on top where it should be.
+             */
+            public void run() {
+                try {
+                    ClassLoader loader = Thread.currentThread().getContextClassLoader();
+                    helpFile = new HelpFile();
+                    helpFile.load(loader.getResourceAsStream("help.xml"));
+                } catch (Exception e) {
+                    new TExceptionDialog(TApplication.this, e);
+                }
+            }
+        });
+    }
+
+    // ------------------------------------------------------------------------
+    // Runnable ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Run this application until it exits.
+     */
+    public void run() {
+        // System.err.println("*** TApplication.run() begins ***");
+
+        // Start the screen updater thread
+        screenHandler = new ScreenHandler(this);
+        (new Thread(screenHandler)).start();
+
+        // Start the main consumer thread
+        primaryEventHandler = new WidgetEventHandler(this, true);
+        (new Thread(primaryEventHandler)).start();
+
+        started = true;
+
+        while (!quit) {
+            synchronized (this) {
+                boolean doWait = false;
+
+                if (!backend.hasEvents()) {
+                    synchronized (fillEventQueue) {
+                        if (fillEventQueue.size() == 0) {
+                            doWait = true;
+                        }
+                    }
+                }
+
+                if (doWait) {
+                    // No I/O to dispatch, so wait until the backend
+                    // provides new I/O.
+                    try {
+                        if (debugThreads) {
+                            System.err.println(System.currentTimeMillis() +
+                                " " + Thread.currentThread() + " MAIN sleep");
+                        }
+
+                        this.wait();
+
+                        if (debugThreads) {
+                            System.err.println(System.currentTimeMillis() +
+                                " " + Thread.currentThread() + " MAIN AWAKE");
+                        }
+                    } catch (InterruptedException e) {
+                        // I'm awake and don't care why, let's see what's
+                        // going on out there.
+                    }
+                }
+
+            } // synchronized (this)
+
+            synchronized (fillEventQueue) {
+                // Pull any pending I/O events
+                backend.getEvents(fillEventQueue);
+
+                // Dispatch each event to the appropriate handler, one at a
+                // time.
+                for (;;) {
+                    TInputEvent event = null;
+                    if (fillEventQueue.size() == 0) {
+                        break;
+                    }
+                    event = fillEventQueue.remove(0);
+                    metaHandleEvent(event);
+                }
+            }
+
+            // Wake a consumer thread if we have any pending events.
+            if (drainEventQueue.size() > 0) {
+                wakeEventHandler();
+            }
+
+        } // while (!quit)
+
+        // Shutdown the event consumer threads
+        if (secondaryEventHandler != null) {
+            synchronized (secondaryEventHandler) {
+                secondaryEventHandler.notify();
+            }
+        }
+        if (primaryEventHandler != null) {
+            synchronized (primaryEventHandler) {
+                primaryEventHandler.notify();
+            }
+        }
+
+        // Close all the windows.  This gives them an opportunity to release
+        // resources.
+        closeAllWindows();
+
+        // Close the desktop.
+        if (desktop != null) {
+            setDesktop(null);
+        }
+
+        // Give the overarching application an opportunity to release
+        // resources.
+        onExit();
+
+        // System.err.println("*** TApplication.run() exits ***");
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Method that TApplication subclasses can override to handle menu or
+     * posted command events.
+     *
+     * @param command command event
+     * @return if true, this event was consumed
+     */
+    protected boolean onCommand(final TCommandEvent command) {
+        // Default: handle cmExit
+        if (command.equals(cmExit)) {
+            if (messageBox(i18n.getString("exitDialogTitle"),
+                    i18n.getString("exitDialogText"),
+                    TMessageBox.Type.YESNO).isYes()) {
+
+                exit();
+            }
+            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;
+        }
+
+        if (command.equals(cmTile)) {
+            tileWindows();
+            return true;
+        }
+        if (command.equals(cmCascade)) {
+            cascadeWindows();
+            return true;
+        }
+        if (command.equals(cmCloseAll)) {
+            closeAllWindows();
+            return true;
+        }
+
+        if (command.equals(cmMenu) && (hideMenuBar == false)) {
+            if (!modalWindowActive() && (activeMenu == null)) {
+                if (menus.size() > 0) {
+                    menus.get(0).setActive(true);
+                    activeMenu = menus.get(0);
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Method that TApplication subclasses can override to handle menu
+     * events.
+     *
+     * @param menu menu event
+     * @return if true, this event was consumed
+     */
+    protected boolean onMenu(final TMenuEvent menu) {
+
+        // Default: handle MID_EXIT
+        if (menu.getId() == TMenu.MID_EXIT) {
+            if (messageBox(i18n.getString("exitDialogTitle"),
+                    i18n.getString("exitDialogText"),
+                    TMessageBox.Type.YESNO).isYes()) {
+
+                exit();
+            }
+            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<String> filters = new ArrayList<String>();
+                filters.add("^.*\\.[Xx][Mm][Ll]$");
+                String filename = fileOpenBox(".", TFileOpenBox.Type.OPEN,
+                    filters);
+                if (filename != null) {
+                    helpTopics = new ArrayList<Topic>();
+                    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;
+        }
+
+        if (menu.getId() == TMenu.MID_TILE) {
+            tileWindows();
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_CASCADE) {
+            cascadeWindows();
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_CLOSE_ALL) {
+            closeAllWindows();
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_ABOUT) {
+            showAboutDialog();
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_REPAINT) {
+            getScreen().clearPhysical();
+            doRepaint();
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_VIEW_IMAGE) {
+            openImage();
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_SCREEN_OPTIONS) {
+            new TFontChooserWindow(this);
+            return true;
+        }
+
+        if (menu.getId() == TMenu.MID_CUT) {
+            postMenuEvent(new TCommandEvent(cmCut));
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_COPY) {
+            postMenuEvent(new TCommandEvent(cmCopy));
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_PASTE) {
+            postMenuEvent(new TCommandEvent(cmPaste));
+            return true;
+        }
+        if (menu.getId() == TMenu.MID_CLEAR) {
+            postMenuEvent(new TCommandEvent(cmClear));
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Method that TApplication subclasses can override to handle keystrokes.
+     *
+     * @param keypress keystroke event
+     * @return if true, this event was consumed
+     */
+    protected boolean onKeypress(final TKeypressEvent keypress) {
+        // Default: only menu shortcuts
+
+        // Process Alt-F, Alt-E, etc. menu shortcut keys
+        if (!keypress.getKey().isFnKey()
+            && keypress.getKey().isAlt()
+            && !keypress.getKey().isCtrl()
+            && (activeMenu == null)
+            && !modalWindowActive()
+            && (hideMenuBar == false)
+        ) {
+
+            assert (subMenus.size() == 0);
+
+            for (TMenu menu: menus) {
+                if (Character.toLowerCase(menu.getMnemonic().getShortcut())
+                    == Character.toLowerCase(keypress.getKey().getChar())
+                ) {
+                    activeMenu = menu;
+                    menu.setActive(true);
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Process background events, and update the screen.
+     */
+    private void finishEventProcessing() {
+        if (debugThreads) {
+            System.err.printf(System.currentTimeMillis() + " " +
+                Thread.currentThread() + " finishEventProcessing()\n");
+        }
+
+        // See if we need to enable/disable the edit menu.
+        EditMenuUser widget = null;
+        if (activeMenu == null) {
+            TWindow activeWindow = getActiveWindow();
+            if (activeWindow != null) {
+                if (activeWindow.getActiveChild() instanceof EditMenuUser) {
+                    widget = (EditMenuUser) activeWindow.getActiveChild();
+                }
+            } else if (desktop != null) {
+                if (desktop.getActiveChild() instanceof EditMenuUser) {
+                    widget = (EditMenuUser) desktop.getActiveChild();
+                }
+            }
+            if (widget == null) {
+                disableMenuItem(TMenu.MID_CUT);
+                disableMenuItem(TMenu.MID_COPY);
+                disableMenuItem(TMenu.MID_PASTE);
+                disableMenuItem(TMenu.MID_CLEAR);
+            } else {
+                if (widget.isEditMenuCut()) {
+                    enableMenuItem(TMenu.MID_CUT);
+                } else {
+                    disableMenuItem(TMenu.MID_CUT);
+                }
+                if (widget.isEditMenuCopy()) {
+                    enableMenuItem(TMenu.MID_COPY);
+                } else {
+                    disableMenuItem(TMenu.MID_COPY);
+                }
+                if (widget.isEditMenuPaste()) {
+                    enableMenuItem(TMenu.MID_PASTE);
+                } else {
+                    disableMenuItem(TMenu.MID_PASTE);
+                }
+                if (widget.isEditMenuClear()) {
+                    enableMenuItem(TMenu.MID_CLEAR);
+                } else {
+                    disableMenuItem(TMenu.MID_CLEAR);
+                }
+            }
+        }
+
+        // Process timers and call doIdle()'s
+        doIdle();
+
+        // Update the screen
+        synchronized (getScreen()) {
+            drawAll();
+        }
+
+        // Wake up the screen repainter
+        wakeScreenHandler();
+
+        if (debugThreads) {
+            System.err.printf(System.currentTimeMillis() + " " +
+                Thread.currentThread() + " finishEventProcessing() END\n");
+        }
+    }
+
+    /**
+     * Peek at certain application-level events, add to eventQueue, and wake
+     * up the consuming Thread.
+     *
+     * @param event the input event to consume
+     */
+    private void metaHandleEvent(final TInputEvent event) {
+
+        if (debugEvents) {
+            System.err.printf(String.format("metaHandleEvents event: %s\n",
+                    event)); System.err.flush();
+        }
+
+        if (quit) {
+            // Do no more processing if the application is already trying
+            // to exit.
+            return;
+        }
+
+        // Special application-wide events -------------------------------
+
+        // Abort everything
+        if (event instanceof TCommandEvent) {
+            TCommandEvent command = (TCommandEvent) event;
+            if (command.equals(cmAbort)) {
+                exit();
+                return;
+            }
+        }
+
+        synchronized (drainEventQueue) {
+            // Screen resize
+            if (event instanceof TResizeEvent) {
+                TResizeEvent resize = (TResizeEvent) event;
+                synchronized (getScreen()) {
+                    if ((System.currentTimeMillis() - screenResizeTime >= 15)
+                        || (resize.getWidth() < getScreen().getWidth())
+                        || (resize.getHeight() < getScreen().getHeight())
+                    ) {
+                        getScreen().setDimensions(resize.getWidth(),
+                            resize.getHeight());
+                        screenResizeTime = System.currentTimeMillis();
+                    }
+                    desktopBottom = getScreen().getHeight() - 1;
+                    if (hideStatusBar) {
+                        desktopBottom++;
+                    }
+                    mouseX = 0;
+                    mouseY = 0;
+                }
+                if (desktop != null) {
+                    desktop.setDimensions(0, desktopTop, resize.getWidth(),
+                        (desktopBottom - desktopTop));
+                    desktop.onResize(resize);
+                }
+
+                // Change menu edges if needed.
+                recomputeMenuX();
+
+                // We are dirty, redraw the screen.
+                doRepaint();
+
+                /*
+                System.err.println("New screen: " + resize.getWidth() +
+                    " x " + resize.getHeight());
+                */
+                return;
+            }
+
+            // Put into the main queue
+            drainEventQueue.add(event);
+        }
+    }
+
+    /**
+     * Dispatch one event to the appropriate widget or application-level
+     * event handler.  This is the primary event handler, it has the normal
+     * application-wide event handling.
+     *
+     * @param event the input event to consume
+     * @see #secondaryHandleEvent(TInputEvent event)
+     */
+    private void primaryHandleEvent(final TInputEvent event) {
+
+        if (debugEvents) {
+            System.err.printf("%s primaryHandleEvent: %s\n",
+                Thread.currentThread(), event);
+        }
+        TMouseEvent doubleClick = null;
+
+        // Special application-wide events -----------------------------------
+
+        if (event instanceof TKeypressEvent) {
+            if (hideMouseWhenTyping) {
+                typingHidMouse = true;
+            }
+        }
+
+        // Peek at the mouse position
+        if (event instanceof TMouseEvent) {
+            typingHidMouse = false;
+
+            TMouseEvent mouse = (TMouseEvent) event;
+            if (mouse.isMouse1() && (mouse.isShift() || mouse.isCtrl())) {
+                // Screen selection.
+                if (inScreenSelection) {
+                    screenSelectionX1 = mouse.getX();
+                    screenSelectionY1 = mouse.getY();
+                } else {
+                    inScreenSelection = true;
+                    screenSelectionX0 = mouse.getX();
+                    screenSelectionY0 = mouse.getY();
+                    screenSelectionX1 = mouse.getX();
+                    screenSelectionY1 = mouse.getY();
+                    screenSelectionRectangle = mouse.isCtrl();
+                }
+            } else {
+                if (inScreenSelection) {
+                    getScreen().copySelection(clipboard, screenSelectionX0,
+                        screenSelectionY0, screenSelectionX1, screenSelectionY1,
+                        screenSelectionRectangle);
+                }
+                inScreenSelection = false;
+            }
+
+            if ((mouseX != mouse.getX()) || (mouseY != mouse.getY())) {
+                mouseX = mouse.getX();
+                mouseY = mouse.getY();
+            } else {
+                if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+                    && (!mouse.isMouseWheelUp())
+                    && (!mouse.isMouseWheelDown())
+                ) {
+                    if ((mouse.getTime().getTime() - lastMouseUpTime) <
+                        doubleClickTime) {
+
+                        // This is a double-click.
+                        doubleClick = new TMouseEvent(TMouseEvent.Type.
+                            MOUSE_DOUBLE_CLICK,
+                            mouse.getX(), mouse.getY(),
+                            mouse.getAbsoluteX(), mouse.getAbsoluteY(),
+                            mouse.isMouse1(), mouse.isMouse2(),
+                            mouse.isMouse3(),
+                            mouse.isMouseWheelUp(), mouse.isMouseWheelDown(),
+                            mouse.isAlt(), mouse.isCtrl(), mouse.isShift());
+
+                    } else {
+                        // The first click of a potential double-click.
+                        lastMouseUpTime = mouse.getTime().getTime();
+                    }
+                }
+            }
+
+            // See if we need to switch focus to another window or the menu
+            checkSwitchFocus((TMouseEvent) event);
+        }
+
+        // Handle menu events
+        if ((activeMenu != null) && !(event instanceof TCommandEvent)) {
+            TMenu menu = activeMenu;
+
+            if (event instanceof TMouseEvent) {
+                TMouseEvent mouse = (TMouseEvent) event;
+
+                while (subMenus.size() > 0) {
+                    TMenu subMenu = subMenus.get(subMenus.size() - 1);
+                    if (subMenu.mouseWouldHit(mouse)) {
+                        break;
+                    }
+                    if ((mouse.getType() == TMouseEvent.Type.MOUSE_MOTION)
+                        && (!mouse.isMouse1())
+                        && (!mouse.isMouse2())
+                        && (!mouse.isMouse3())
+                        && (!mouse.isMouseWheelUp())
+                        && (!mouse.isMouseWheelDown())
+                    ) {
+                        break;
+                    }
+                    // We navigated away from a sub-menu, so close it
+                    closeSubMenu();
+                }
+
+                // Convert the mouse relative x/y to menu coordinates
+                assert (mouse.getX() == mouse.getAbsoluteX());
+                assert (mouse.getY() == mouse.getAbsoluteY());
+                if (subMenus.size() > 0) {
+                    menu = subMenus.get(subMenus.size() - 1);
+                }
+                mouse.setX(mouse.getX() - menu.getX());
+                mouse.setY(mouse.getY() - menu.getY());
+            }
+            menu.handleEvent(event);
+            return;
+        }
+
+        if (event instanceof TKeypressEvent) {
+            TKeypressEvent keypress = (TKeypressEvent) event;
+
+            // See if this key matches an accelerator, and is not being
+            // shortcutted by the active window, and if so dispatch the menu
+            // event.
+            boolean windowWillShortcut = false;
+            TWindow activeWindow = getActiveWindow();
+            if (activeWindow != null) {
+                assert (activeWindow.isShown());
+                if (activeWindow.isShortcutKeypress(keypress.getKey())) {
+                    // We do not process this key, it will be passed to the
+                    // window instead.
+                    windowWillShortcut = true;
+                }
+            }
+
+            if (!windowWillShortcut && !modalWindowActive()) {
+                TKeypress keypressLowercase = keypress.getKey().toLowerCase();
+                TMenuItem item = null;
+                synchronized (accelerators) {
+                    item = accelerators.get(keypressLowercase);
+                }
+                if (item != null) {
+                    if (item.isEnabled()) {
+                        // Let the menu item dispatch
+                        item.dispatch();
+                        return;
+                    }
+                }
+
+                // Handle the keypress
+                if (onKeypress(keypress)) {
+                    return;
+                }
+            }
+        }
+
+        if (event instanceof TCommandEvent) {
+            if (onCommand((TCommandEvent) event)) {
+                return;
+            }
+        }
+
+        if (event instanceof TMenuEvent) {
+            if (onMenu((TMenuEvent) event)) {
+                return;
+            }
+        }
+
+        // Dispatch events to the active window -------------------------------
+        boolean dispatchToDesktop = true;
+        TWindow window = getActiveWindow();
+        if (window != null) {
+            assert (window.isActive());
+            assert (window.isShown());
+            if (event instanceof TMouseEvent) {
+                TMouseEvent mouse = (TMouseEvent) event;
+                // Convert the mouse relative x/y to window coordinates
+                assert (mouse.getX() == mouse.getAbsoluteX());
+                assert (mouse.getY() == mouse.getAbsoluteY());
+                mouse.setX(mouse.getX() - window.getX());
+                mouse.setY(mouse.getY() - window.getY());
+
+                if (doubleClick != null) {
+                    doubleClick.setX(doubleClick.getX() - window.getX());
+                    doubleClick.setY(doubleClick.getY() - window.getY());
+                }
+
+                if (window.mouseWouldHit(mouse)) {
+                    dispatchToDesktop = false;
+                }
+            } else if (event instanceof TKeypressEvent) {
+                dispatchToDesktop = false;
+            } else if (event instanceof TMenuEvent) {
+                dispatchToDesktop = false;
+            }
+
+            if (debugEvents) {
+                System.err.printf("TApplication dispatch event: %s\n",
+                    event);
+            }
+            window.handleEvent(event);
+            if (doubleClick != null) {
+                window.handleEvent(doubleClick);
+            }
+        }
+        if (dispatchToDesktop) {
+            // This event is fair game for the desktop to process.
+            if (desktop != null) {
+                desktop.handleEvent(event);
+                if (doubleClick != null) {
+                    desktop.handleEvent(doubleClick);
+                }
+            }
+        }
+    }
+
+    /**
+     * Dispatch one event to the appropriate widget or application-level
+     * event handler.  This is the secondary event handler used by certain
+     * special dialogs (currently TMessageBox and TFileOpenBox).
+     *
+     * @param event the input event to consume
+     * @see #primaryHandleEvent(TInputEvent event)
+     */
+    private void secondaryHandleEvent(final TInputEvent event) {
+        TMouseEvent doubleClick = null;
+
+        if (debugEvents) {
+            System.err.printf("%s secondaryHandleEvent: %s\n",
+                Thread.currentThread(), event);
+        }
+
+        // Peek at the mouse position
+        if (event instanceof TMouseEvent) {
+            typingHidMouse = false;
+
+            TMouseEvent mouse = (TMouseEvent) event;
+            if ((mouseX != mouse.getX()) || (mouseY != mouse.getY())) {
+                mouseX = mouse.getX();
+                mouseY = mouse.getY();
+            } else {
+                if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+                    && (!mouse.isMouseWheelUp())
+                    && (!mouse.isMouseWheelDown())
+                ) {
+                    if ((mouse.getTime().getTime() - lastMouseUpTime) <
+                        doubleClickTime) {
+
+                        // This is a double-click.
+                        doubleClick = new TMouseEvent(TMouseEvent.Type.
+                            MOUSE_DOUBLE_CLICK,
+                            mouse.getX(), mouse.getY(),
+                            mouse.getAbsoluteX(), mouse.getAbsoluteY(),
+                            mouse.isMouse1(), mouse.isMouse2(),
+                            mouse.isMouse3(),
+                            mouse.isMouseWheelUp(), mouse.isMouseWheelDown(),
+                            mouse.isAlt(), mouse.isCtrl(), mouse.isShift());
+
+                    } else {
+                        // The first click of a potential double-click.
+                        lastMouseUpTime = mouse.getTime().getTime();
+                    }
+                }
+            }
+        }
+
+        secondaryEventReceiver.handleEvent(event);
+        // Note that it is possible for secondaryEventReceiver to be null
+        // now, because its handleEvent() might have finished out on the
+        // secondary thread.  So put any extra processing inside a null
+        // check.
+        if (secondaryEventReceiver != null) {
+            if (doubleClick != null) {
+                secondaryEventReceiver.handleEvent(doubleClick);
+            }
+        }
+    }
+
+    /**
+     * Enable a widget to override the primary event thread.
+     *
+     * @param widget widget that will receive events
+     */
+    public final void enableSecondaryEventReceiver(final TWidget widget) {
+        if (debugThreads) {
+            System.err.println(System.currentTimeMillis() +
+                " enableSecondaryEventReceiver()");
+        }
+
+        assert (secondaryEventReceiver == null);
+        assert (secondaryEventHandler == null);
+        assert ((widget instanceof TMessageBox)
+            || (widget instanceof TFileOpenBox));
+        secondaryEventReceiver = widget;
+        secondaryEventHandler = new WidgetEventHandler(this, false);
+
+        (new Thread(secondaryEventHandler)).start();
+    }
+
+    /**
+     * Yield to the secondary thread.
+     */
+    public final void yield() {
+        if (debugThreads) {
+            System.err.printf(System.currentTimeMillis() + " " +
+                Thread.currentThread() + " yield()\n");
+        }
+
+        assert (secondaryEventReceiver != null);
+
+        while (secondaryEventReceiver != null) {
+            synchronized (primaryEventHandler) {
+                try {
+                    primaryEventHandler.wait();
+                } catch (InterruptedException e) {
+                    // SQUASH
+                }
+            }
+        }
+    }
+
+    /**
+     * Do stuff when there is no user input.
+     */
+    private void doIdle() {
+        if (debugThreads) {
+            System.err.printf(System.currentTimeMillis() + " " +
+                Thread.currentThread() + " doIdle()\n");
+        }
+
+        synchronized (timers) {
+
+            if (debugThreads) {
+                System.err.printf(System.currentTimeMillis() + " " +
+                    Thread.currentThread() + " doIdle() 2\n");
+            }
+
+            // Run any timers that have timed out
+            Date now = new Date();
+            List<TTimer> keepTimers = new LinkedList<TTimer>();
+            for (TTimer timer: timers) {
+                if (timer.getNextTick().getTime() <= now.getTime()) {
+                    // Something might change, so repaint the screen.
+                    repaint = true;
+                    timer.tick();
+                    if (timer.recurring) {
+                        keepTimers.add(timer);
+                    }
+                } else {
+                    keepTimers.add(timer);
+                }
+            }
+            timers.clear();
+            timers.addAll(keepTimers);
+        }
+
+        // Call onIdle's
+        for (TWindow window: windows) {
+            window.onIdle();
+        }
+        if (desktop != null) {
+            desktop.onIdle();
+        }
+
+        // Run any invokeLaters.  We make a copy, and run that, because one
+        // of these Runnables might add call TApplication.invokeLater().
+        List<Runnable> invokes = new ArrayList<Runnable>();
+        synchronized (invokeLaters) {
+            invokes.addAll(invokeLaters);
+            invokeLaters.clear();
+        }
+        for (Runnable invoke: invokes) {
+            invoke.run();
+        }
+        doRepaint();
+
+    }
+
+    /**
+     * Wake the sleeping active event handler.
+     */
+    private void wakeEventHandler() {
+        if (!started) {
+            return;
+        }
+
+        if (secondaryEventHandler != null) {
+            synchronized (secondaryEventHandler) {
+                secondaryEventHandler.notify();
+            }
+        } else {
+            assert (primaryEventHandler != null);
+            synchronized (primaryEventHandler) {
+                primaryEventHandler.notify();
+            }
+        }
+    }
+
+    /**
+     * Wake the sleeping screen handler.
+     */
+    private void wakeScreenHandler() {
+        if (!started) {
+            return;
+        }
+
+        synchronized (screenHandler) {
+            screenHandler.notify();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TApplication -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Place a command on the run queue, and run it before the next round of
+     * checking I/O.
+     *
+     * @param command the command to run later
+     */
+    public void invokeLater(final Runnable command) {
+        synchronized (invokeLaters) {
+            invokeLaters.add(command);
+        }
+        doRepaint();
+    }
+
+    /**
+     * Restore the console to sane defaults.  This is meant to be used for
+     * improper exits (e.g. a caught exception in main()), and should not be
+     * necessary for normal program termination.
+     */
+    public void restoreConsole() {
+        if (backend != null) {
+            if (backend instanceof ECMA48Backend) {
+                backend.shutdown();
+            }
+        }
+    }
+
+    /**
+     * Get the Backend.
+     *
+     * @return the Backend
+     */
+    public final Backend getBackend() {
+        return backend;
+    }
+
+    /**
+     * Get the Screen.
+     *
+     * @return the Screen
+     */
+    public final Screen getScreen() {
+        if (backend instanceof TWindowBackend) {
+            // We are being rendered to a TWindow.  We can't use its
+            // getScreen() method because that is how it is rendering to a
+            // hardware backend somewhere.  Instead use its getOtherScreen()
+            // method.
+            return ((TWindowBackend) backend).getOtherScreen();
+        } else {
+            return backend.getScreen();
+        }
+    }
+
+    /**
+     * Get the color theme.
+     *
+     * @return the theme
+     */
+    public final ColorTheme getTheme() {
+        return theme;
+    }
+
+    /**
+     * Get the clipboard.
+     *
+     * @return the clipboard
+     */
+    public final Clipboard getClipboard() {
+        return clipboard;
+    }
+
+    /**
+     * Repaint the screen on the next update.
+     */
+    public void doRepaint() {
+        repaint = true;
+        wakeEventHandler();
+    }
+
+    /**
+     * Get Y coordinate of the top edge of the desktop.
+     *
+     * @return Y coordinate of the top edge of the desktop
+     */
+    public final int getDesktopTop() {
+        return desktopTop;
+    }
+
+    /**
+     * Get Y coordinate of the bottom edge of the desktop.
+     *
+     * @return Y coordinate of the bottom edge of the desktop
+     */
+    public final int getDesktopBottom() {
+        return desktopBottom;
+    }
+
+    /**
+     * Set the TDesktop instance.
+     *
+     * @param desktop a TDesktop instance, or null to remove the one that is
+     * set
+     */
+    public final void setDesktop(final TDesktop desktop) {
+        if (this.desktop != null) {
+            this.desktop.onPreClose();
+            this.desktop.onUnfocus();
+            this.desktop.onClose();
+        }
+        this.desktop = desktop;
+    }
+
+    /**
+     * Get the TDesktop instance.
+     *
+     * @return the desktop, or null if it is not set
+     */
+    public final TDesktop getDesktop() {
+        return desktop;
+    }
+
+    /**
+     * Get the current active window.
+     *
+     * @return the active window, or null if it is not set
+     */
+    public final TWindow getActiveWindow() {
+        for (TWindow window: windows) {
+            if (window.isShown() && window.isActive()) {
+                return window;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Get a (shallow) copy of the window list.
+     *
+     * @return a copy of the list of windows for this application
+     */
+    public final List<TWindow> getAllWindows() {
+        List<TWindow> result = new ArrayList<TWindow>();
+        result.addAll(windows);
+        return result;
+    }
+
+    /**
+     * Get focusFollowsMouse flag.
+     *
+     * @return true if focus follows mouse: windows automatically raised if
+     * the mouse passes over them
+     */
+    public boolean getFocusFollowsMouse() {
+        return focusFollowsMouse;
+    }
+
+    /**
+     * Set focusFollowsMouse flag.
+     *
+     * @param focusFollowsMouse if true, focus follows mouse: windows
+     * automatically raised if the mouse passes over them
+     */
+    public void setFocusFollowsMouse(final boolean focusFollowsMouse) {
+        this.focusFollowsMouse = focusFollowsMouse;
+    }
+
+    /**
+     * Display the about dialog.
+     */
+    protected void showAboutDialog() {
+        String version = getClass().getPackage().getImplementationVersion();
+        if (version == null) {
+            // This is Java 9+, use a hardcoded string here.
+            version = "1.0.0";
+        }
+        messageBox(i18n.getString("aboutDialogTitle"),
+            MessageFormat.format(i18n.getString("aboutDialogText"), version),
+            TMessageBox.Type.OK);
+    }
+
+    /**
+     * Handle the Tool | Open image menu item.
+     */
+    private void openImage() {
+        try {
+            List<String> filters = new ArrayList<String>();
+            filters.add("^.*\\.[Jj][Pp][Gg]$");
+            filters.add("^.*\\.[Jj][Pp][Ee][Gg]$");
+            filters.add("^.*\\.[Pp][Nn][Gg]$");
+            filters.add("^.*\\.[Gg][Ii][Ff]$");
+            filters.add("^.*\\.[Bb][Mm][Pp]$");
+            String filename = fileOpenBox(".", TFileOpenBox.Type.OPEN, filters);
+            if (filename != null) {
+                new TImageWindow(this, new File(filename));
+            }
+        } catch (IOException e) {
+            // Show this exception to the user.
+            new TExceptionDialog(this, e);
+        }
+    }
+
+    /**
+     * Check if application is still running.
+     *
+     * @return true if the application is running
+     */
+    public final boolean isRunning() {
+        if (quit == true) {
+            return false;
+        }
+        return true;
+    }
+
+    // ------------------------------------------------------------------------
+    // Screen refresh loop ----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the text mouse at position.
+     *
+     * @param x column position
+     * @param y row position
+     */
+    private void drawTextMouse(final int x, final int y) {
+        TWindow activeWindow = getActiveWindow();
+
+        if (debugThreads) {
+            System.err.printf("%d %s drawTextMouse() %d %d\n",
+                System.currentTimeMillis(), Thread.currentThread(), x, y);
+
+            if (activeWindow != null) {
+                System.err.println("activeWindow.hasHiddenMouse() " +
+                    activeWindow.hasHiddenMouse());
+            }
+        }
+
+        // If this cell is on top of a visible window that has requested a
+        // hidden mouse, bail out.
+        if ((activeWindow != null) && (activeMenu == null)) {
+            if ((activeWindow.hasHiddenMouse() == true)
+                && (x > activeWindow.getX())
+                && (x < activeWindow.getX() + activeWindow.getWidth() - 1)
+                && (y > activeWindow.getY())
+                && (y < activeWindow.getY() + activeWindow.getHeight() - 1)
+            ) {
+                return;
+            }
+        }
+
+        // If this cell is on top of the desktop, and the desktop has
+        // requested a hidden mouse, bail out.
+        if ((desktop != null) && (activeWindow == null) && (activeMenu == null)) {
+            if ((desktop.hasHiddenMouse() == true)
+                && (x > desktop.getX())
+                && (x < desktop.getX() + desktop.getWidth() - 1)
+                && (y > desktop.getY())
+                && (y < desktop.getY() + desktop.getHeight() - 1)
+            ) {
+                return;
+            }
+        }
+
+        getScreen().invertCell(x, y);
+    }
+
+    /**
+     * Draw everything.
+     */
+    private void drawAll() {
+        boolean menuIsActive = false;
+
+        if (debugThreads) {
+            System.err.printf("%d %s drawAll() enter\n",
+                System.currentTimeMillis(), Thread.currentThread());
+        }
+
+        // I don't think this does anything useful anymore...
+        if (!repaint) {
+            if (debugThreads) {
+                System.err.printf("%d %s drawAll() !repaint\n",
+                    System.currentTimeMillis(), Thread.currentThread());
+            }
+            if ((oldDrawnMouseX != mouseX) || (oldDrawnMouseY != mouseY)) {
+                if (debugThreads) {
+                    System.err.printf("%d %s drawAll() !repaint MOUSE\n",
+                        System.currentTimeMillis(), Thread.currentThread());
+                }
+
+                // The only thing that has happened is the mouse moved.
+
+                // Redraw the old cell at that position, and save the cell at
+                // the new mouse position.
+                if (debugThreads) {
+                    System.err.printf("%d %s restoreImage() %d %d\n",
+                        System.currentTimeMillis(), Thread.currentThread(),
+                        oldDrawnMouseX, oldDrawnMouseY);
+                }
+                oldDrawnMouseCell.restoreImage();
+                getScreen().putCharXY(oldDrawnMouseX, oldDrawnMouseY,
+                    oldDrawnMouseCell);
+                oldDrawnMouseCell = getScreen().getCharXY(mouseX, mouseY);
+                if (backend instanceof ECMA48Backend) {
+                    // Special case: the entire row containing the mouse has
+                    // to be re-drawn if it has any image data, AND any rows
+                    // in between.
+                    if (oldDrawnMouseY != mouseY) {
+                        for (int i = oldDrawnMouseY; ;) {
+                            getScreen().unsetImageRow(i);
+                            if (i == mouseY) {
+                                break;
+                            }
+                            if (oldDrawnMouseY < mouseY) {
+                                i++;
+                            } else {
+                                i--;
+                            }
+                        }
+                    } else {
+                        getScreen().unsetImageRow(mouseY);
+                    }
+                }
+
+                if (inScreenSelection) {
+                    getScreen().setSelection(screenSelectionX0,
+                        screenSelectionY0, screenSelectionX1, screenSelectionY1,
+                        screenSelectionRectangle);
+                }
+
+                if ((textMouse == true) && (typingHidMouse == false)) {
+                    // Draw mouse at the new position.
+                    drawTextMouse(mouseX, mouseY);
+                }
+
+                oldDrawnMouseX = mouseX;
+                oldDrawnMouseY = mouseY;
+            }
+            if (getScreen().isDirty()) {
+                screenHandler.setDirty();
+            }
+            return;
+        }
+
+        if (debugThreads) {
+            System.err.printf("%d %s drawAll() REDRAW\n",
+                System.currentTimeMillis(), Thread.currentThread());
+        }
+
+        // If true, the cursor is not visible
+        boolean cursor = false;
+
+        // Start with a clean screen
+        getScreen().clear();
+
+        // Draw the desktop
+        if (desktop != null) {
+            desktop.drawChildren();
+        }
+
+        // Draw each window in reverse Z order
+        List<TWindow> sorted = new ArrayList<TWindow>(windows);
+        Collections.sort(sorted);
+        TWindow topLevel = null;
+        if (sorted.size() > 0) {
+            topLevel = sorted.get(0);
+        }
+        Collections.reverse(sorted);
+        for (TWindow window: sorted) {
+            if (window.isShown()) {
+                window.drawChildren();
+            }
+        }
+
+        if (hideMenuBar == false) {
+
+            // Draw the blank menubar line - reset the screen clipping first
+            // so it won't trim it out.
+            getScreen().resetClipping();
+            getScreen().hLineXY(0, 0, getScreen().getWidth(), ' ',
+                theme.getColor("tmenu"));
+            // Now draw the menus.
+            int x = 1;
+            for (TMenu menu: menus) {
+                CellAttributes menuColor;
+                CellAttributes menuMnemonicColor;
+                if (menu.isActive()) {
+                    menuIsActive = true;
+                    menuColor = theme.getColor("tmenu.highlighted");
+                    menuMnemonicColor = theme.getColor("tmenu.mnemonic.highlighted");
+                    topLevel = menu;
+                } else {
+                    menuColor = theme.getColor("tmenu");
+                    menuMnemonicColor = theme.getColor("tmenu.mnemonic");
+                }
+                // Draw the menu title
+                getScreen().hLineXY(x, 0,
+                    StringUtils.width(menu.getTitle()) + 2, ' ', menuColor);
+                getScreen().putStringXY(x + 1, 0, menu.getTitle(), menuColor);
+                // Draw the highlight character
+                getScreen().putCharXY(x + 1 +
+                    menu.getMnemonic().getScreenShortcutIdx(),
+                    0, menu.getMnemonic().getShortcut(), menuMnemonicColor);
+
+                if (menu.isActive()) {
+                    ((TWindow) menu).drawChildren();
+                    // Reset the screen clipping so we can draw the next
+                    // title.
+                    getScreen().resetClipping();
+                }
+                x += StringUtils.width(menu.getTitle()) + 2;
+            }
+
+            for (TMenu menu: subMenus) {
+                // Reset the screen clipping so we can draw the next
+                // sub-menu.
+                getScreen().resetClipping();
+                ((TWindow) menu).drawChildren();
+            }
+        }
+        getScreen().resetClipping();
+
+        if (hideStatusBar == false) {
+            // Draw the status bar of the top-level window
+            TStatusBar statusBar = null;
+            if (topLevel != null) {
+                if (topLevel.isShown()) {
+                    statusBar = topLevel.getStatusBar();
+                }
+            }
+            if (statusBar != null) {
+                getScreen().resetClipping();
+                statusBar.setWidth(getScreen().getWidth());
+                statusBar.setY(getScreen().getHeight() - topLevel.getY());
+                statusBar.draw();
+            } else {
+                CellAttributes barColor = new CellAttributes();
+                barColor.setTo(getTheme().getColor("tstatusbar.text"));
+                getScreen().hLineXY(0, desktopBottom, getScreen().getWidth(),
+                    ' ', barColor);
+            }
+        }
+
+        // Draw the mouse pointer
+        if (debugThreads) {
+            System.err.printf("%d %s restoreImage() %d %d\n",
+                System.currentTimeMillis(), Thread.currentThread(),
+                oldDrawnMouseX, oldDrawnMouseY);
+        }
+        oldDrawnMouseCell = getScreen().getCharXY(mouseX, mouseY);
+        if (backend instanceof ECMA48Backend) {
+            // Special case: the entire row containing the mouse has to be
+            // re-drawn if it has any image data, AND any rows in between.
+            if (oldDrawnMouseY != mouseY) {
+                for (int i = oldDrawnMouseY; ;) {
+                    getScreen().unsetImageRow(i);
+                    if (i == mouseY) {
+                        break;
+                    }
+                    if (oldDrawnMouseY < mouseY) {
+                        i++;
+                    } else {
+                        i--;
+                    }
+                }
+            } else {
+                getScreen().unsetImageRow(mouseY);
+            }
+        }
+
+        if (inScreenSelection) {
+            getScreen().setSelection(screenSelectionX0, screenSelectionY0,
+                screenSelectionX1, screenSelectionY1, screenSelectionRectangle);
+        }
+
+        if ((textMouse == true) && (typingHidMouse == false)) {
+            drawTextMouse(mouseX, mouseY);
+        }
+        oldDrawnMouseX = mouseX;
+        oldDrawnMouseY = mouseY;
+
+        // Place the cursor if it is visible
+        if (!menuIsActive) {
+
+            int visibleWindowCount = 0;
+            for (TWindow window: sorted) {
+                if (window.isShown()) {
+                    visibleWindowCount++;
+                }
+            }
+            if (visibleWindowCount == 0) {
+                // No windows are visible, only the desktop.  Allow it to
+                // have the cursor.
+                if (desktop != null) {
+                    sorted.add(desktop);
+                }
+            }
+
+            TWidget activeWidget = null;
+            if (sorted.size() > 0) {
+                activeWidget = sorted.get(sorted.size() - 1).getActiveChild();
+                int cursorClipTop = desktopTop;
+                int cursorClipBottom = desktopBottom;
+                if (activeWidget.isCursorVisible()) {
+                    if ((activeWidget.getCursorAbsoluteY() <= cursorClipBottom)
+                        && (activeWidget.getCursorAbsoluteY() >= cursorClipTop)
+                    ) {
+                        getScreen().putCursor(true,
+                            activeWidget.getCursorAbsoluteX(),
+                            activeWidget.getCursorAbsoluteY());
+                        cursor = true;
+                    } else {
+                        // Turn off the cursor.  Also place it at 0,0.
+                        getScreen().putCursor(false, 0, 0);
+                        cursor = false;
+                    }
+                }
+            }
+        }
+
+        // Kill the cursor
+        if (!cursor) {
+            getScreen().hideCursor();
+        }
+
+        if (getScreen().isDirty()) {
+            screenHandler.setDirty();
+        }
+        repaint = false;
+    }
+
+    /**
+     * Force this application to exit.
+     */
+    public void exit() {
+        quit = true;
+        synchronized (this) {
+            this.notify();
+        }
+    }
+
+    /**
+     * Subclasses can use this hook to cleanup resources.  Called as the last
+     * step of TApplication.run().
+     */
+    public void onExit() {
+        // Default does nothing.
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow management -----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Return the total number of windows.
+     *
+     * @return the total number of windows
+     */
+    public final int windowCount() {
+        return windows.size();
+    }
+
+    /**
+     * Return the number of windows that are showing.
+     *
+     * @return the number of windows that are showing on screen
+     */
+    public final int shownWindowCount() {
+        int n = 0;
+        for (TWindow w: windows) {
+            if (w.isShown()) {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    /**
+     * Return the number of windows that are hidden.
+     *
+     * @return the number of windows that are hidden
+     */
+    public final int hiddenWindowCount() {
+        int n = 0;
+        for (TWindow w: windows) {
+            if (w.isHidden()) {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    /**
+     * Check if a window instance is in this application's window list.
+     *
+     * @param window window to look for
+     * @return true if this window is in the list
+     */
+    public final boolean hasWindow(final TWindow window) {
+        if (windows.size() == 0) {
+            return false;
+        }
+        for (TWindow w: windows) {
+            if (w == window) {
+                assert (window.getApplication() == this);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Activate a window: bring it to the top and have it receive events.
+     *
+     * @param window the window to become the new active window
+     */
+    public final void activateWindow(final TWindow window) {
+        if (hasWindow(window) == false) {
+            /*
+             * Someone has a handle to a window I don't have.  Ignore this
+             * request.
+             */
+            return;
+        }
+
+        if (modalWindowActive() && !window.isModal()) {
+            // Do not activate a non-modal on top of a modal.
+            return;
+        }
+
+        synchronized (windows) {
+            // Whatever window might be moving/dragging, stop it now.
+            for (TWindow w: windows) {
+                if (w.inMovements()) {
+                    w.stopMovements();
+                }
+            }
+
+            assert (windows.size() > 0);
+
+            if (window.isHidden()) {
+                // Unhiding will also activate.
+                showWindow(window);
+                return;
+            }
+            assert (window.isShown());
+
+            if (windows.size() == 1) {
+                assert (window == windows.get(0));
+                window.setZ(0);
+                window.setActive(true);
+                window.onFocus();
+                return;
+            }
+
+            if (getActiveWindow() == window) {
+                assert (window.isActive());
+
+                // Window is already active, do nothing.
+                return;
+            }
+
+            assert (!window.isActive());
+
+            window.setZ(-1);
+            Collections.sort(windows);
+            int newZ = 0;
+            for (TWindow w: windows) {
+                w.setZ(newZ);
+                newZ++;
+                if ((w != window) && w.isActive()) {
+                    w.onUnfocus();
+                }
+                w.setActive(false);
+            }
+            window.setActive(true);
+            window.onFocus();
+
+        } // synchronized (windows)
+
+        return;
+    }
+
+    /**
+     * Hide a window.
+     *
+     * @param window the window to hide
+     */
+    public void hideWindow(final TWindow window) {
+        if (hasWindow(window) == false) {
+            /*
+             * Someone has a handle to a window I don't have.  Ignore this
+             * request.
+             */
+            return;
+        }
+
+        synchronized (windows) {
+
+            // Whatever window might be moving/dragging, stop it now.
+            for (TWindow w: windows) {
+                if (w.inMovements()) {
+                    w.stopMovements();
+                }
+            }
+
+            assert (windows.size() > 0);
+
+            if (window.hidden) {
+                return;
+            }
+
+            window.setActive(false);
+            window.hidden = true;
+            window.onHide();
+
+            TWindow activeWindow = null;
+            for (TWindow w: windows) {
+                if (w.isShown()) {
+                    activeWindow = w;
+                    break;
+                }
+            }
+            assert (activeWindow != window);
+            if (activeWindow != null) {
+                activateWindow(activeWindow);
+            }
+
+        } // synchronized (windows)
+
+    }
+
+    /**
+     * Show a window.
+     *
+     * @param window the window to show
+     */
+    public void showWindow(final TWindow window) {
+        if (hasWindow(window) == false) {
+            /*
+             * Someone has a handle to a window I don't have.  Ignore this
+             * request.
+             */
+            return;
+        }
+
+        if (window.hidden) {
+            window.hidden = false;
+            window.onShow();
+            activateWindow(window);
+        }
+
+    }
+
+    /**
+     * Close window.
+     *
+     * @param window the window to remove
+     */
+    public final void closeWindow(final TWindow window) {
+        if (hasWindow(window) == false) {
+            /*
+             * Someone has a handle to a window I don't have.  Ignore this
+             * request.
+             */
+            return;
+        }
+
+        // Let window know that it is about to be closed, while it is still
+        // visible on screen.
+        window.onPreClose();
+
+        synchronized (windows) {
+
+            window.stopMovements();
+            window.onUnfocus();
+            windows.remove(window);
+            Collections.sort(windows);
+
+            TWindow nextWindow = null;
+            int newZ = 0;
+            for (TWindow w: windows) {
+                w.stopMovements();
+                w.setZ(newZ);
+                newZ++;
+
+                // Do not activate a hidden window.
+                if (w.isHidden()) {
+                    continue;
+                }
+                if (nextWindow == null) {
+                    nextWindow = w;
+                } else {
+                    if (w.isActive()) {
+                        w.setActive(false);
+                        w.onUnfocus();
+                    }
+                }
+            }
+
+            if (nextWindow != null) {
+                nextWindow.setActive(true);
+                nextWindow.onFocus();
+            }
+
+        } // synchronized (windows)
+
+        // Perform window cleanup
+        window.onClose();
+
+        // Check if we are closing a TMessageBox or similar
+        if (secondaryEventReceiver != null) {
+            assert (secondaryEventHandler != null);
+
+            // Do not send events to the secondaryEventReceiver anymore, the
+            // window is closed.
+            secondaryEventReceiver = null;
+
+            // Wake the secondary thread, it will wake the primary as it
+            // exits.
+            synchronized (secondaryEventHandler) {
+                secondaryEventHandler.notify();
+            }
+
+        } // synchronized (windows)
+
+        // Permit desktop to be active if it is the only thing left.
+        if (desktop != null) {
+            if (windows.size() == 0) {
+                desktop.setActive(true);
+            }
+        }
+    }
+
+    /**
+     * Switch to the next window.
+     *
+     * @param forward if true, then switch to the next window in the list,
+     * otherwise switch to the previous window in the list
+     */
+    public final void switchWindow(final boolean forward) {
+        // Only switch if there are multiple visible windows
+        if (shownWindowCount() < 2) {
+            return;
+        }
+
+        if (modalWindowActive()) {
+            // Do not switch if a window is modal
+            return;
+        }
+
+        synchronized (windows) {
+
+            TWindow window = windows.get(0);
+            do {
+                assert (window != null);
+                if (forward) {
+                    window.setZ(windows.size());
+                } else {
+                    TWindow lastWindow = windows.get(windows.size() - 1);
+                    lastWindow.setZ(-1);
+                }
+
+                Collections.sort(windows);
+                int newZ = 0;
+                for (TWindow w: windows) {
+                    w.setZ(newZ);
+                    newZ++;
+                }
+
+                window = windows.get(0);
+            } while (!window.isShown());
+
+            // The next visible window is now on top.  Renumber the list.
+            for (TWindow w: windows) {
+                w.stopMovements();
+                if ((w != window) && w.isActive()) {
+                    assert (w.isShown());
+                    w.setActive(false);
+                    w.onUnfocus();
+                }
+            }
+
+            // Next visible window is on top.
+            assert (window.isShown());
+            window.setActive(true);
+            window.onFocus();
+
+        } // synchronized (windows)
+    }
+
+    /**
+     * Add a window to my window list and make it active.  Note package
+     * private access.
+     *
+     * @param window new window to add
+     */
+    final void addWindowToApplication(final TWindow window) {
+
+        // Do not add menu windows to the window list.
+        if (window instanceof TMenu) {
+            return;
+        }
+
+        // Do not add the desktop to the window list.
+        if (window instanceof TDesktop) {
+            return;
+        }
+
+        synchronized (windows) {
+            if (windows.contains(window)) {
+                throw new IllegalArgumentException("Window " + window +
+                    " is already in window list");
+            }
+
+            // Whatever window might be moving/dragging, stop it now.
+            for (TWindow w: windows) {
+                if (w.inMovements()) {
+                    w.stopMovements();
+                }
+            }
+
+            // Do not allow a modal window to spawn a non-modal window.  If a
+            // modal window is active, then this window will become modal
+            // too.
+            if (modalWindowActive()) {
+                window.flags |= TWindow.MODAL;
+                window.flags |= TWindow.CENTERED;
+                window.hidden = false;
+            }
+            if (window.isShown()) {
+                for (TWindow w: windows) {
+                    if (w.isActive()) {
+                        w.setActive(false);
+                        w.onUnfocus();
+                    }
+                    w.setZ(w.getZ() + 1);
+                }
+                window.setZ(0);
+                window.setActive(true);
+                window.onFocus();
+                windows.add(0, window);
+            } else {
+                window.setZ(windows.size());
+                windows.add(window);
+            }
+
+            if (((window.flags & TWindow.CENTERED) == 0)
+                && ((window.flags & TWindow.ABSOLUTEXY) == 0)
+                && (smartWindowPlacement == true)
+                && (!(window instanceof TDesktop))
+            ) {
+
+                doSmartPlacement(window);
+            }
+        }
+
+        // Desktop cannot be active over any other window.
+        if (desktop != null) {
+            desktop.setActive(false);
+        }
+
+    }
+
+    /**
+     * Check if there is a system-modal window on top.
+     *
+     * @return true if the active window is modal
+     */
+    private boolean modalWindowActive() {
+        if (windows.size() == 0) {
+            return false;
+        }
+
+        for (TWindow w: windows) {
+            if (w.isModal()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Check if there is a window with overridden menu flag on top.
+     *
+     * @return true if the active window is overriding the menu
+     */
+    private boolean overrideMenuWindowActive() {
+        TWindow activeWindow = getActiveWindow();
+        if (activeWindow != null) {
+            if (activeWindow.hasOverriddenMenu()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Close all open windows.
+     */
+    private void closeAllWindows() {
+        // Don't do anything if we are in the menu
+        if (activeMenu != null) {
+            return;
+        }
+        while (windows.size() > 0) {
+            closeWindow(windows.get(0));
+        }
+    }
+
+    /**
+     * Re-layout the open windows as non-overlapping tiles.  This produces
+     * almost the same results as Turbo Pascal 7.0's IDE.
+     */
+    private void tileWindows() {
+        synchronized (windows) {
+            // Don't do anything if we are in the menu
+            if (activeMenu != null) {
+                return;
+            }
+            int z = windows.size();
+            if (z == 0) {
+                return;
+            }
+            int a = 0;
+            int b = 0;
+            a = (int)(Math.sqrt(z));
+            int c = 0;
+            while (c < a) {
+                b = (z - c) / a;
+                if (((a * b) + c) == z) {
+                    break;
+                }
+                c++;
+            }
+            assert (a > 0);
+            assert (b > 0);
+            assert (c < a);
+            int newWidth = (getScreen().getWidth() / a);
+            int newHeight1 = ((getScreen().getHeight() - 1) / b);
+            int newHeight2 = ((getScreen().getHeight() - 1) / (b + c));
+
+            List<TWindow> sorted = new ArrayList<TWindow>(windows);
+            Collections.sort(sorted);
+            Collections.reverse(sorted);
+            for (int i = 0; i < sorted.size(); i++) {
+                int logicalX = i / b;
+                int logicalY = i % b;
+                if (i >= ((a - 1) * b)) {
+                    logicalX = a - 1;
+                    logicalY = i - ((a - 1) * b);
+                }
+
+                TWindow w = sorted.get(i);
+                int oldWidth = w.getWidth();
+                int oldHeight = w.getHeight();
+
+                w.setX(logicalX * newWidth);
+                w.setWidth(newWidth);
+                if (i >= ((a - 1) * b)) {
+                    w.setY((logicalY * newHeight2) + 1);
+                    w.setHeight(newHeight2);
+                } else {
+                    w.setY((logicalY * newHeight1) + 1);
+                    w.setHeight(newHeight1);
+                }
+                if ((w.getWidth() != oldWidth)
+                    || (w.getHeight() != oldHeight)
+                ) {
+                    w.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                            w.getWidth(), w.getHeight()));
+                }
+            }
+        }
+    }
+
+    /**
+     * Re-layout the open windows as overlapping cascaded windows.
+     */
+    private void cascadeWindows() {
+        synchronized (windows) {
+            // Don't do anything if we are in the menu
+            if (activeMenu != null) {
+                return;
+            }
+            int x = 0;
+            int y = 1;
+            List<TWindow> sorted = new ArrayList<TWindow>(windows);
+            Collections.sort(sorted);
+            Collections.reverse(sorted);
+            for (TWindow window: sorted) {
+                window.setX(x);
+                window.setY(y);
+                x++;
+                y++;
+                if (x > getScreen().getWidth()) {
+                    x = 0;
+                }
+                if (y >= getScreen().getHeight()) {
+                    y = 1;
+                }
+            }
+        }
+    }
+
+    /**
+     * Place a window to minimize its overlap with other windows.
+     *
+     * @param window the window to place
+     */
+    public final void doSmartPlacement(final TWindow window) {
+        // This is a pretty dumb algorithm, but seems to work.  The hardest
+        // part is computing these "overlap" values seeking a minimum average
+        // overlap.
+        int xMin = 0;
+        int yMin = desktopTop;
+        int xMax = getScreen().getWidth() - window.getWidth() + 1;
+        int yMax = desktopBottom  - window.getHeight() + 1;
+        if (xMax < xMin) {
+            xMax = xMin;
+        }
+        if (yMax < yMin) {
+            yMax = yMin;
+        }
+
+        if ((xMin == xMax) && (yMin == yMax)) {
+            // No work to do, bail out.
+            return;
+        }
+
+        // Compute the overlap matrix without the new window.
+        int width = getScreen().getWidth();
+        int height = getScreen().getHeight();
+        int overlapMatrix[][] = new int[width][height];
+        for (TWindow w: windows) {
+            if (window == w) {
+                continue;
+            }
+            for (int x = w.getX(); x < w.getX() + w.getWidth(); x++) {
+                if (x < 0) {
+                    continue;
+                }
+                if (x >= width) {
+                    continue;
+                }
+                for (int y = w.getY(); y < w.getY() + w.getHeight(); y++) {
+                    if (y < 0) {
+                        continue;
+                    }
+                    if (y >= height) {
+                        continue;
+                    }
+                    overlapMatrix[x][y]++;
+                }
+            }
+        }
+
+        long oldOverlapTotal = 0;
+        long oldOverlapN = 0;
+        for (int x = 0; x < width; x++) {
+            for (int y = 0; y < height; y++) {
+                oldOverlapTotal += overlapMatrix[x][y];
+                if (overlapMatrix[x][y] > 0) {
+                    oldOverlapN++;
+                }
+            }
+        }
+
+
+        double oldOverlapAvg = (double) oldOverlapTotal / (double) oldOverlapN;
+        boolean first = true;
+        int windowX = window.getX();
+        int windowY = window.getY();
+
+        // For each possible (x, y) position for the new window, compute a
+        // new overlap matrix.
+        for (int x = xMin; x < xMax; x++) {
+            for (int y = yMin; y < yMax; y++) {
+
+                // Start with the matrix minus this window.
+                int newMatrix[][] = new int[width][height];
+                for (int mx = 0; mx < width; mx++) {
+                    for (int my = 0; my < height; my++) {
+                        newMatrix[mx][my] = overlapMatrix[mx][my];
+                    }
+                }
+
+                // Add this window's values to the new overlap matrix.
+                long newOverlapTotal = 0;
+                long newOverlapN = 0;
+                // Start by adding each new cell.
+                for (int wx = x; wx < x + window.getWidth(); wx++) {
+                    if (wx >= width) {
+                        continue;
+                    }
+                    for (int wy = y; wy < y + window.getHeight(); wy++) {
+                        if (wy >= height) {
+                            continue;
+                        }
+                        newMatrix[wx][wy]++;
+                    }
+                }
+                // Now figure out the new value for total coverage.
+                for (int mx = 0; mx < width; mx++) {
+                    for (int my = 0; my < height; my++) {
+                        newOverlapTotal += newMatrix[x][y];
+                        if (newMatrix[mx][my] > 0) {
+                            newOverlapN++;
+                        }
+                    }
+                }
+                double newOverlapAvg = (double) newOverlapTotal / (double) newOverlapN;
+
+                if (first) {
+                    // First time: just record what we got.
+                    oldOverlapAvg = newOverlapAvg;
+                    first = false;
+                } else {
+                    // All other times: pick a new best (x, y) and save the
+                    // overlap value.
+                    if (newOverlapAvg < oldOverlapAvg) {
+                        windowX = x;
+                        windowY = y;
+                        oldOverlapAvg = newOverlapAvg;
+                    }
+                }
+
+            } // for (int x = xMin; x < xMax; x++)
+
+        } // for (int y = yMin; y < yMax; y++)
+
+        // Finally, set the window's new coordinates.
+        window.setX(windowX);
+        window.setY(windowY);
+    }
+
+    // ------------------------------------------------------------------------
+    // TMenu management -------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if a mouse event would hit either the active menu or any open
+     * sub-menus.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse would hit the active menu or an open
+     * sub-menu
+     */
+    private boolean mouseOnMenu(final TMouseEvent mouse) {
+        assert (activeMenu != null);
+        List<TMenu> menus = new ArrayList<TMenu>(subMenus);
+        Collections.reverse(menus);
+        for (TMenu menu: menus) {
+            if (menu.mouseWouldHit(mouse)) {
+                return true;
+            }
+        }
+        return activeMenu.mouseWouldHit(mouse);
+    }
+
+    /**
+     * See if we need to switch window or activate the menu based on
+     * a mouse click.
+     *
+     * @param mouse mouse event
+     */
+    private void checkSwitchFocus(final TMouseEvent mouse) {
+
+        if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+            && (activeMenu != null)
+            && (mouse.getAbsoluteY() != 0)
+            && (!mouseOnMenu(mouse))
+        ) {
+            // They clicked outside the active menu, turn it off
+            activeMenu.setActive(false);
+            activeMenu = null;
+            for (TMenu menu: subMenus) {
+                menu.setActive(false);
+            }
+            subMenus.clear();
+            // Continue checks
+        }
+
+        // See if they hit the menu bar
+        if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+            && (mouse.isMouse1())
+            && (!modalWindowActive())
+            && (!overrideMenuWindowActive())
+            && (mouse.getAbsoluteY() == 0)
+            && (hideMenuBar == false)
+        ) {
+
+            for (TMenu menu: subMenus) {
+                menu.setActive(false);
+            }
+            subMenus.clear();
+
+            // They selected the menu, go activate it
+            for (TMenu menu: menus) {
+                if ((mouse.getAbsoluteX() >= menu.getTitleX())
+                    && (mouse.getAbsoluteX() < menu.getTitleX()
+                        + StringUtils.width(menu.getTitle()) + 2)
+                ) {
+                    menu.setActive(true);
+                    activeMenu = menu;
+                } else {
+                    menu.setActive(false);
+                }
+            }
+            return;
+        }
+
+        // See if they hit the menu bar
+        if ((mouse.getType() == TMouseEvent.Type.MOUSE_MOTION)
+            && (mouse.isMouse1())
+            && (activeMenu != null)
+            && (mouse.getAbsoluteY() == 0)
+            && (hideMenuBar == false)
+        ) {
+
+            TMenu oldMenu = activeMenu;
+            for (TMenu menu: subMenus) {
+                menu.setActive(false);
+            }
+            subMenus.clear();
+
+            // See if we should switch menus
+            for (TMenu menu: menus) {
+                if ((mouse.getAbsoluteX() >= menu.getTitleX())
+                    && (mouse.getAbsoluteX() < menu.getTitleX()
+                        + StringUtils.width(menu.getTitle()) + 2)
+                ) {
+                    menu.setActive(true);
+                    activeMenu = menu;
+                }
+            }
+            if (oldMenu != activeMenu) {
+                // They switched menus
+                oldMenu.setActive(false);
+            }
+            return;
+        }
+
+        // If a menu is still active, don't switch windows
+        if (activeMenu != null) {
+            return;
+        }
+
+        // Only switch if there are multiple windows
+        if (windows.size() < 2) {
+            return;
+        }
+
+        if (((focusFollowsMouse == true)
+                && (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION))
+            || (mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+        ) {
+            synchronized (windows) {
+                if (windows.get(0).isModal()) {
+                    // Modal windows don't switch
+                    return;
+                }
+
+                for (TWindow window: windows) {
+                    assert (!window.isModal());
+
+                    if (window.isHidden()) {
+                        assert (!window.isActive());
+                        continue;
+                    }
+
+                    if (window.mouseWouldHit(mouse)) {
+                        activateWindow(window);
+                        return;
+                    }
+                }
+            }
+
+            // Clicked on the background, nothing to do
+            return;
+        }
+
+        // Nothing to do: this isn't a mouse up, or focus isn't following
+        // mouse.
+        return;
+    }
+
+    /**
+     * Turn off the menu.
+     */
+    public final void closeMenu() {
+        if (activeMenu != null) {
+            activeMenu.setActive(false);
+            activeMenu = null;
+            for (TMenu menu: subMenus) {
+                menu.setActive(false);
+            }
+            subMenus.clear();
+        }
+    }
+
+    /**
+     * Get a (shallow) copy of the menu list.
+     *
+     * @return a copy of the menu list
+     */
+    public final List<TMenu> getAllMenus() {
+        return new ArrayList<TMenu>(menus);
+    }
+
+    /**
+     * Add a top-level menu to the list.
+     *
+     * @param menu the menu to add
+     * @throws IllegalArgumentException if the menu is already used in
+     * another TApplication
+     */
+    public final void addMenu(final TMenu menu) {
+        if ((menu.getApplication() != null)
+            && (menu.getApplication() != this)
+        ) {
+            throw new IllegalArgumentException("Menu " + menu + " is already " +
+                "part of application " + menu.getApplication());
+        }
+        closeMenu();
+        menus.add(menu);
+        recomputeMenuX();
+    }
+
+    /**
+     * Remove a top-level menu from the list.
+     *
+     * @param menu the menu to remove
+     * @throws IllegalArgumentException if the menu is already used in
+     * another TApplication
+     */
+    public final void removeMenu(final TMenu menu) {
+        if ((menu.getApplication() != null)
+            && (menu.getApplication() != this)
+        ) {
+            throw new IllegalArgumentException("Menu " + menu + " is already " +
+                "part of application " + menu.getApplication());
+        }
+        closeMenu();
+        menus.remove(menu);
+        recomputeMenuX();
+    }
+
+    /**
+     * Turn off a sub-menu.
+     */
+    public final void closeSubMenu() {
+        assert (activeMenu != null);
+        TMenu item = subMenus.get(subMenus.size() - 1);
+        assert (item != null);
+        item.setActive(false);
+        subMenus.remove(subMenus.size() - 1);
+    }
+
+    /**
+     * Switch to the next menu.
+     *
+     * @param forward if true, then switch to the next menu in the list,
+     * otherwise switch to the previous menu in the list
+     */
+    public final void switchMenu(final boolean forward) {
+        assert (activeMenu != null);
+        assert (hideMenuBar == false);
+
+        for (TMenu menu: subMenus) {
+            menu.setActive(false);
+        }
+        subMenus.clear();
+
+        for (int i = 0; i < menus.size(); i++) {
+            if (activeMenu == menus.get(i)) {
+                if (forward) {
+                    if (i < menus.size() - 1) {
+                        i++;
+                    } else {
+                        i = 0;
+                    }
+                } else {
+                    if (i > 0) {
+                        i--;
+                    } else {
+                        i = menus.size() - 1;
+                    }
+                }
+                activeMenu.setActive(false);
+                activeMenu = menus.get(i);
+                activeMenu.setActive(true);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Add a menu item to the global list.  If it has a keyboard accelerator,
+     * that will be added the global hash.
+     *
+     * @param item the menu item
+     */
+    public final void addMenuItem(final TMenuItem item) {
+        menuItems.add(item);
+
+        TKeypress key = item.getKey();
+        if (key != null) {
+            synchronized (accelerators) {
+                assert (accelerators.get(key) == null);
+                accelerators.put(key.toLowerCase(), item);
+            }
+        }
+    }
+
+    /**
+     * Disable one menu item.
+     *
+     * @param id the menu item ID
+     */
+    public final void disableMenuItem(final int id) {
+        for (TMenuItem item: menuItems) {
+            if (item.getId() == id) {
+                item.setEnabled(false);
+            }
+        }
+    }
+
+    /**
+     * Disable the range of menu items with ID's between lower and upper,
+     * inclusive.
+     *
+     * @param lower the lowest menu item ID
+     * @param upper the highest menu item ID
+     */
+    public final void disableMenuItems(final int lower, final int upper) {
+        for (TMenuItem item: menuItems) {
+            if ((item.getId() >= lower) && (item.getId() <= upper)) {
+                item.setEnabled(false);
+                item.getParent().activate(0);
+            }
+        }
+    }
+
+    /**
+     * Enable one menu item.
+     *
+     * @param id the menu item ID
+     */
+    public final void enableMenuItem(final int id) {
+        for (TMenuItem item: menuItems) {
+            if (item.getId() == id) {
+                item.setEnabled(true);
+                item.getParent().activate(0);
+            }
+        }
+    }
+
+    /**
+     * Enable the range of menu items with ID's between lower and upper,
+     * inclusive.
+     *
+     * @param lower the lowest menu item ID
+     * @param upper the highest menu item ID
+     */
+    public final void enableMenuItems(final int lower, final int upper) {
+        for (TMenuItem item: menuItems) {
+            if ((item.getId() >= lower) && (item.getId() <= upper)) {
+                item.setEnabled(true);
+                item.getParent().activate(0);
+            }
+        }
+    }
+
+    /**
+     * Get the menu item associated with this ID.
+     *
+     * @param id the menu item ID
+     * @return the menu item, or null if not found
+     */
+    public final TMenuItem getMenuItem(final int id) {
+        for (TMenuItem item: menuItems) {
+            if (item.getId() == id) {
+                return item;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Recompute menu x positions based on their title length.
+     */
+    public final void recomputeMenuX() {
+        int x = 0;
+        for (TMenu menu: menus) {
+            menu.setX(x);
+            menu.setTitleX(x);
+            x += StringUtils.width(menu.getTitle()) + 2;
+
+            // Don't let the menu window exceed the screen width
+            int rightEdge = menu.getX() + menu.getWidth();
+            if (rightEdge > getScreen().getWidth()) {
+                menu.setX(getScreen().getWidth() - menu.getWidth());
+            }
+        }
+    }
+
+    /**
+     * Post an event to process.
+     *
+     * @param event new event to add to the queue
+     */
+    public final void postEvent(final TInputEvent event) {
+        synchronized (this) {
+            synchronized (fillEventQueue) {
+                fillEventQueue.add(event);
+            }
+            if (debugThreads) {
+                System.err.println(System.currentTimeMillis() + " " +
+                    Thread.currentThread() + " postEvent() wake up main");
+            }
+            this.notify();
+        }
+    }
+
+    /**
+     * Post an event to process and turn off the menu.
+     *
+     * @param event new event to add to the queue
+     */
+    public final void postMenuEvent(final TInputEvent event) {
+        synchronized (this) {
+            synchronized (fillEventQueue) {
+                fillEventQueue.add(event);
+            }
+            if (debugThreads) {
+                System.err.println(System.currentTimeMillis() + " " +
+                    Thread.currentThread() + " postMenuEvent() wake up main");
+            }
+            closeMenu();
+            this.notify();
+        }
+    }
+
+    /**
+     * Add a sub-menu to the list of open sub-menus.
+     *
+     * @param menu sub-menu
+     */
+    public final void addSubMenu(final TMenu menu) {
+        subMenus.add(menu);
+    }
+
+    /**
+     * Convenience function to add a top-level menu.
+     *
+     * @param title menu title
+     * @return the new menu
+     */
+    public final TMenu addMenu(final String title) {
+        int x = 0;
+        int y = 0;
+        TMenu menu = new TMenu(this, x, y, title);
+        menus.add(menu);
+        recomputeMenuX();
+        return menu;
+    }
+
+    /**
+     * Convenience function to add a default tools (hamburger) menu.
+     *
+     * @return the new menu
+     */
+    public final TMenu addToolMenu() {
+        TMenu toolMenu = addMenu(i18n.getString("toolMenuTitle"));
+        toolMenu.addDefaultItem(TMenu.MID_REPAINT);
+        toolMenu.addDefaultItem(TMenu.MID_VIEW_IMAGE);
+        toolMenu.addDefaultItem(TMenu.MID_SCREEN_OPTIONS);
+        TStatusBar toolStatusBar = toolMenu.newStatusBar(i18n.
+            getString("toolMenuStatus"));
+        toolStatusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+        return toolMenu;
+    }
+
+    /**
+     * Convenience function to add a default "File" menu.
+     *
+     * @return the new menu
+     */
+    public final TMenu addFileMenu() {
+        TMenu fileMenu = addMenu(i18n.getString("fileMenuTitle"));
+        fileMenu.addDefaultItem(TMenu.MID_SHELL);
+        fileMenu.addSeparator();
+        fileMenu.addDefaultItem(TMenu.MID_EXIT);
+        TStatusBar statusBar = fileMenu.newStatusBar(i18n.
+            getString("fileMenuStatus"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+        return fileMenu;
+    }
+
+    /**
+     * Convenience function to add a default "Edit" menu.
+     *
+     * @return the new menu
+     */
+    public final TMenu addEditMenu() {
+        TMenu editMenu = addMenu(i18n.getString("editMenuTitle"));
+        editMenu.addDefaultItem(TMenu.MID_UNDO, false);
+        editMenu.addDefaultItem(TMenu.MID_REDO, false);
+        editMenu.addSeparator();
+        editMenu.addDefaultItem(TMenu.MID_CUT, false);
+        editMenu.addDefaultItem(TMenu.MID_COPY, false);
+        editMenu.addDefaultItem(TMenu.MID_PASTE, false);
+        editMenu.addDefaultItem(TMenu.MID_CLEAR, false);
+        TStatusBar statusBar = editMenu.newStatusBar(i18n.
+            getString("editMenuStatus"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+        return editMenu;
+    }
+
+    /**
+     * Convenience function to add a default "Window" menu.
+     *
+     * @return the new menu
+     */
+    public final TMenu addWindowMenu() {
+        TMenu windowMenu = addMenu(i18n.getString("windowMenuTitle"));
+        windowMenu.addDefaultItem(TMenu.MID_TILE);
+        windowMenu.addDefaultItem(TMenu.MID_CASCADE);
+        windowMenu.addDefaultItem(TMenu.MID_CLOSE_ALL);
+        windowMenu.addSeparator();
+        windowMenu.addDefaultItem(TMenu.MID_WINDOW_MOVE);
+        windowMenu.addDefaultItem(TMenu.MID_WINDOW_ZOOM);
+        windowMenu.addDefaultItem(TMenu.MID_WINDOW_NEXT);
+        windowMenu.addDefaultItem(TMenu.MID_WINDOW_PREVIOUS);
+        windowMenu.addDefaultItem(TMenu.MID_WINDOW_CLOSE);
+        TStatusBar statusBar = windowMenu.newStatusBar(i18n.
+            getString("windowMenuStatus"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+        return windowMenu;
+    }
+
+    /**
+     * Convenience function to add a default "Help" menu.
+     *
+     * @return the new menu
+     */
+    public final TMenu addHelpMenu() {
+        TMenu helpMenu = addMenu(i18n.getString("helpMenuTitle"));
+        helpMenu.addDefaultItem(TMenu.MID_HELP_CONTENTS);
+        helpMenu.addDefaultItem(TMenu.MID_HELP_INDEX);
+        helpMenu.addDefaultItem(TMenu.MID_HELP_SEARCH);
+        helpMenu.addDefaultItem(TMenu.MID_HELP_PREVIOUS);
+        helpMenu.addDefaultItem(TMenu.MID_HELP_HELP);
+        helpMenu.addDefaultItem(TMenu.MID_HELP_ACTIVE_FILE);
+        helpMenu.addSeparator();
+        helpMenu.addDefaultItem(TMenu.MID_ABOUT);
+        TStatusBar statusBar = helpMenu.newStatusBar(i18n.
+            getString("helpMenuStatus"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+        return helpMenu;
+    }
+
+    /**
+     * Convenience function to add a default "Table" menu.
+     *
+     * @return the new menu
+     */
+    public final TMenu addTableMenu() {
+        TMenu tableMenu = addMenu(i18n.getString("tableMenuTitle"));
+        tableMenu.addDefaultItem(TMenu.MID_TABLE_RENAME_COLUMN, false);
+        tableMenu.addDefaultItem(TMenu.MID_TABLE_RENAME_ROW, false);
+        tableMenu.addSeparator();
+
+        TSubMenu viewMenu = tableMenu.addSubMenu(i18n.
+            getString("tableSubMenuView"));
+        viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_ROW_LABELS, false);
+        viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS, false);
+        viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW, false);
+        viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN, false);
+
+        TSubMenu borderMenu = tableMenu.addSubMenu(i18n.
+            getString("tableSubMenuBorders"));
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_NONE, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_ALL, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_CELL_NONE, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_CELL_ALL, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_RIGHT, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_LEFT, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_TOP, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_BOTTOM, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM, false);
+        borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_THICK_BOTTOM, false);
+        TSubMenu deleteMenu = tableMenu.addSubMenu(i18n.
+            getString("tableSubMenuDelete"));
+        deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_LEFT, false);
+        deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_UP, false);
+        deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_ROW, false);
+        deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_COLUMN, false);
+        TSubMenu insertMenu = tableMenu.addSubMenu(i18n.
+            getString("tableSubMenuInsert"));
+        insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_LEFT, false);
+        insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_RIGHT, false);
+        insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_ABOVE, false);
+        insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_BELOW, false);
+        TSubMenu columnMenu = tableMenu.addSubMenu(i18n.
+            getString("tableSubMenuColumn"));
+        columnMenu.addDefaultItem(TMenu.MID_TABLE_COLUMN_NARROW, false);
+        columnMenu.addDefaultItem(TMenu.MID_TABLE_COLUMN_WIDEN, false);
+        TSubMenu fileMenu = tableMenu.addSubMenu(i18n.
+            getString("tableSubMenuFile"));
+        fileMenu.addDefaultItem(TMenu.MID_TABLE_FILE_OPEN_CSV, false);
+        fileMenu.addDefaultItem(TMenu.MID_TABLE_FILE_SAVE_CSV, false);
+        fileMenu.addDefaultItem(TMenu.MID_TABLE_FILE_SAVE_TEXT, false);
+
+        TStatusBar statusBar = tableMenu.newStatusBar(i18n.
+            getString("tableMenuStatus"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+        return tableMenu;
+    }
+
+    // ------------------------------------------------------------------------
+    // TTimer management ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the amount of time I can sleep before missing a Timer tick.
+     *
+     * @param timeout = initial (maximum) timeout in millis
+     * @return number of milliseconds between now and the next timer event
+     */
+    private long getSleepTime(final long timeout) {
+        Date now = new Date();
+        long nowTime = now.getTime();
+        long sleepTime = timeout;
+
+        synchronized (timers) {
+            for (TTimer timer: timers) {
+                long nextTickTime = timer.getNextTick().getTime();
+                if (nextTickTime < nowTime) {
+                    return 0;
+                }
+
+                long timeDifference = nextTickTime - nowTime;
+                if (timeDifference < sleepTime) {
+                    sleepTime = timeDifference;
+                }
+            }
+        }
+
+        assert (sleepTime >= 0);
+        assert (sleepTime <= timeout);
+        return sleepTime;
+    }
+
+    /**
+     * Convenience function to add a timer.
+     *
+     * @param duration number of milliseconds to wait between ticks
+     * @param recurring if true, re-schedule this timer after every tick
+     * @param action function to call when button is pressed
+     * @return the timer
+     */
+    public final TTimer addTimer(final long duration, final boolean recurring,
+        final TAction action) {
+
+        TTimer timer = new TTimer(duration, recurring, action);
+        synchronized (timers) {
+            timers.add(timer);
+        }
+        return timer;
+    }
+
+    /**
+     * Convenience function to remove a timer.
+     *
+     * @param timer timer to remove
+     */
+    public final void removeTimer(final TTimer timer) {
+        synchronized (timers) {
+            timers.remove(timer);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Other TWindow constructors ---------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Convenience function to spawn a message box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @return the new message box
+     */
+    public final TMessageBox messageBox(final String title,
+        final String caption) {
+
+        return new TMessageBox(this, title, caption, TMessageBox.Type.OK);
+    }
+
+    /**
+     * Convenience function to spawn a message box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param type one of the TMessageBox.Type constants.  Default is
+     * Type.OK.
+     * @return the new message box
+     */
+    public final TMessageBox messageBox(final String title,
+        final String caption, final TMessageBox.Type type) {
+
+        return new TMessageBox(this, title, caption, type);
+    }
+
+    /**
+     * Convenience function to spawn an input box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @return the new input box
+     */
+    public final TInputBox inputBox(final String title, final String caption) {
+
+        return new TInputBox(this, title, caption);
+    }
+
+    /**
+     * Convenience function to spawn an input box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param text initial text to seed the field with
+     * @return the new input box
+     */
+    public final TInputBox inputBox(final String title, final String caption,
+        final String text) {
+
+        return new TInputBox(this, title, caption, text);
+    }
+
+    /**
+     * Convenience function to spawn an input box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param text initial text to seed the field with
+     * @param type one of the Type constants.  Default is Type.OK.
+     * @return the new input box
+     */
+    public final TInputBox inputBox(final String title, final String caption,
+        final String text, final TInputBox.Type type) {
+
+        return new TInputBox(this, title, caption, text, type);
+    }
+
+    /**
+     * Convenience function to open a terminal window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y) {
+        return openTerminal(x, y, TWindow.RESIZABLE);
+    }
+
+    /**
+     * Convenience function to open a terminal window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param closeOnExit if true, close the window when the command exits
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final boolean closeOnExit) {
+
+        return openTerminal(x, y, TWindow.RESIZABLE, closeOnExit);
+    }
+
+    /**
+     * Convenience function to open a terminal window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final int flags) {
+
+        return new TTerminalWindow(this, x, y, flags);
+    }
+
+    /**
+     * Convenience function to open a terminal window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param closeOnExit if true, close the window when the command exits
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final int flags, final boolean closeOnExit) {
+
+        return new TTerminalWindow(this, x, y, flags, closeOnExit);
+    }
+
+    /**
+     * Convenience function to open a terminal window and execute a custom
+     * command line inside it.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param commandLine the command line to execute
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final String commandLine) {
+
+        return openTerminal(x, y, TWindow.RESIZABLE, commandLine);
+    }
+
+    /**
+     * Convenience function to open a terminal window and execute a custom
+     * command line inside it.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param commandLine the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final String commandLine, final boolean closeOnExit) {
+
+        return openTerminal(x, y, TWindow.RESIZABLE, commandLine, closeOnExit);
+    }
+
+    /**
+     * Convenience function to open a terminal window and execute a custom
+     * command line inside it.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param command the command line to execute
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final int flags, final String [] command) {
+
+        return new TTerminalWindow(this, x, y, flags, command);
+    }
+
+    /**
+     * Convenience function to open a terminal window and execute a custom
+     * command line inside it.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param command the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final int flags, final String [] command, final boolean closeOnExit) {
+
+        return new TTerminalWindow(this, x, y, flags, command, closeOnExit);
+    }
+
+    /**
+     * Convenience function to open a terminal window and execute a custom
+     * command line inside it.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param commandLine the command line to execute
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final int flags, final String commandLine) {
+
+        return new TTerminalWindow(this, x, y, flags, commandLine.split("\\s+"));
+    }
+
+    /**
+     * Convenience function to open a terminal window and execute a custom
+     * command line inside it.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param commandLine the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     * @return the terminal new window
+     */
+    public final TTerminalWindow openTerminal(final int x, final int y,
+        final int flags, final String commandLine, final boolean closeOnExit) {
+
+        return new TTerminalWindow(this, x, y, flags, commandLine.split("\\s+"),
+            closeOnExit);
+    }
+
+    /**
+     * Convenience function to spawn an file open box.
+     *
+     * @param path path of selected file
+     * @return the result of the new file open box
+     * @throws IOException if java.io operation throws
+     */
+    public final String fileOpenBox(final String path) throws IOException {
+
+        TFileOpenBox box = new TFileOpenBox(this, path, TFileOpenBox.Type.OPEN);
+        return box.getFilename();
+    }
+
+    /**
+     * Convenience function to spawn an file open box.
+     *
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @return the result of the new file open box
+     * @throws IOException if java.io operation throws
+     */
+    public final String fileOpenBox(final String path,
+        final TFileOpenBox.Type type) throws IOException {
+
+        TFileOpenBox box = new TFileOpenBox(this, path, type);
+        return box.getFilename();
+    }
+
+    /**
+     * Convenience function to spawn a file open box.
+     *
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @param filter a string that files must match to be displayed
+     * @return the result of the new file open box
+     * @throws IOException of a java.io operation throws
+     */
+    public final String fileOpenBox(final String path,
+        final TFileOpenBox.Type type, final String filter) throws IOException {
+
+        ArrayList<String> filters = new ArrayList<String>();
+        filters.add(filter);
+
+        TFileOpenBox box = new TFileOpenBox(this, path, type, filters);
+        return box.getFilename();
+    }
+
+    /**
+     * Convenience function to spawn a file open box.
+     *
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @param filters a list of strings that files must match to be displayed
+     * @return the result of the new file open box
+     * @throws IOException of a java.io operation throws
+     */
+    public final String fileOpenBox(final String path,
+        final TFileOpenBox.Type type,
+        final List<String> filters) throws IOException {
+
+        TFileOpenBox box = new TFileOpenBox(this, path, type, filters);
+        return box.getFilename();
+    }
+
+    /**
+     * Convenience function to create a new window and make it active.
+     * Window will be located at (0, 0).
+     *
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     * @return the new window
+     */
+    public final TWindow addWindow(final String title, final int width,
+        final int height) {
+
+        TWindow window = new TWindow(this, title, 0, 0, width, height);
+        return window;
+    }
+
+    /**
+     * Convenience function to create a new window and make it active.
+     * Window will be located at (0, 0).
+     *
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+     * @return the new window
+     */
+    public final TWindow addWindow(final String title,
+        final int width, final int height, final int flags) {
+
+        TWindow window = new TWindow(this, title, 0, 0, width, height, flags);
+        return window;
+    }
+
+    /**
+     * Convenience function to create a new window and make it active.
+     *
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     * @return the new window
+     */
+    public final TWindow addWindow(final String title,
+        final int x, final int y, final int width, final int height) {
+
+        TWindow window = new TWindow(this, title, x, y, width, height);
+        return window;
+    }
+
+    /**
+     * Convenience function to create a new window and make it active.
+     *
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     * @param flags mask of RESIZABLE, CENTERED, or MODAL
+     * @return the new window
+     */
+    public final TWindow addWindow(final String title,
+        final int x, final int y, final int width, final int height,
+        final int flags) {
+
+        TWindow window = new TWindow(this, title, x, y, width, height, flags);
+        return window;
+    }
+
+}
diff --git a/src/jexer/TApplication.properties b/src/jexer/TApplication.properties
new file mode 100644 (file)
index 0000000..57f7c59
--- /dev/null
@@ -0,0 +1,30 @@
+Help=Help
+
+toolMenuTitle=&\u2261
+toolMenuStatus=Additional tools
+fileMenuTitle=&File
+fileMenuStatus=File-management commands (Open, Save, Print, etc.)
+editMenuTitle=&Edit
+editMenuStatus=Editor operations, undo, and Clipboard access
+windowMenuTitle=&Window
+windowMenuStatus=Open, arrange, and list windows
+helpMenuTitle=&Help
+helpMenuStatus=Access online help
+
+tableMenuTitle=&Table
+tableSubMenuView=&View
+tableSubMenuBorders=&Borders
+tableSubMenuDelete=&Delete
+tableSubMenuInsert=&Insert
+tableSubMenuColumn=&Column
+tableSubMenuFile=&File
+tableMenuStatus=Table manipulation commands
+
+exitDialogTitle=Confirmation
+exitDialogText=Exit application?
+
+aboutDialogTitle=About
+aboutDialogText=Jexer Version {0}
+
+searchHelpInputBoxTitle=Search Help Topics
+searchHelpInputBoxCaption=Search help topics for (regex):
diff --git a/src/jexer/TButton.java b/src/jexer/TButton.java
new file mode 100644 (file)
index 0000000..d1d7b39
--- /dev/null
@@ -0,0 +1,347 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.Color;
+import jexer.bits.GraphicsChars;
+import jexer.bits.MnemonicString;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.kbEnter;
+import static jexer.TKeypress.kbSpace;
+
+/**
+ * TButton implements a simple button.  To make the button do something, pass
+ * a TAction class to its constructor.
+ *
+ * @see TAction#DO()
+ */
+public class TButton extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The shortcut and button text.
+     */
+    private MnemonicString mnemonic;
+
+    /**
+     * Remember mouse state.
+     */
+    private TMouseEvent mouse;
+
+    /**
+     * True when the button is being pressed and held down.
+     */
+    private boolean inButtonPress = false;
+
+    /**
+     * The action to perform when the button is clicked.
+     */
+    private TAction action;
+
+    /**
+     * The background color used for the button "shadow", or null for "no
+     * shadow".
+     */
+    private CellAttributes shadowColor;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Private constructor.
+     *
+     * @param parent parent widget
+     * @param text label on the button
+     * @param x column relative to parent
+     * @param y row relative to parent
+     */
+    private TButton(final TWidget parent, final String text,
+        final int x, final int y) {
+
+        // Set parent and window
+        super(parent);
+
+        mnemonic = new MnemonicString(text);
+
+        setX(x);
+        setY(y);
+        super.setHeight(2);
+        super.setWidth(StringUtils.width(mnemonic.getRawLabel()) + 3);
+
+        shadowColor = new CellAttributes();
+        shadowColor.setTo(getWindow().getBackground());
+        shadowColor.setForeColor(Color.BLACK);
+        shadowColor.setBold(false);
+
+        // Since we set dimensions after TWidget's constructor, we need to
+        // update the layout manager.
+        if (getParent().getLayoutManager() != null) {
+            getParent().getLayoutManager().remove(this);
+            getParent().getLayoutManager().add(this);
+        }
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text label on the button
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param action to call when button is pressed
+     */
+    public TButton(final TWidget parent, final String text,
+        final int x, final int y, final TAction action) {
+
+        this(parent, text, x, y);
+        this.action = action;
+    }
+    
+    /**
+     * The action to call when the button is pressed.
+     **/
+    public TAction getAction() {
+               return action;
+       }
+    
+    /**
+     * The action to call when the button is pressed.
+     **/
+    public void setAction(TAction action) {
+               this.action = action;
+       }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the button.
+     *
+     * @return if true the mouse is currently on the button
+     */
+    private boolean mouseOnButton() {
+        int rightEdge = getWidth() - 1;
+        if (inButtonPress) {
+            rightEdge++;
+        }
+        if ((mouse != null)
+            && (mouse.getY() == 0)
+            && (mouse.getX() >= 0)
+            && (mouse.getX() < rightEdge)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if ((mouseOnButton()) && (mouse.isMouse1())) {
+            // Begin button press
+            inButtonPress = true;
+        }
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if (inButtonPress && mouse.isMouse1()) {
+            // Dispatch the event
+            dispatch();
+        }
+
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if (!mouseOnButton()) {
+            inButtonPress = false;
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbEnter)
+            || keypress.equals(kbSpace)
+        ) {
+            // Dispatch
+            dispatch();
+            return;
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's width: we can only set width at construction time.
+     *
+     * @param width new widget width (ignored)
+     */
+    @Override
+    public void setWidth(final int width) {
+        // Do nothing
+    }
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw a button with a shadow.
+     */
+    @Override
+    public void draw() {
+        CellAttributes buttonColor;
+        CellAttributes menuMnemonicColor;
+
+        if (!isEnabled()) {
+            buttonColor = getTheme().getColor("tbutton.disabled");
+            menuMnemonicColor = getTheme().getColor("tbutton.disabled");
+        } else if (isAbsoluteActive()) {
+            buttonColor = getTheme().getColor("tbutton.active");
+            menuMnemonicColor = getTheme().getColor("tbutton.mnemonic.highlighted");
+        } else {
+            buttonColor = getTheme().getColor("tbutton.inactive");
+            menuMnemonicColor = getTheme().getColor("tbutton.mnemonic");
+        }
+
+        if (inButtonPress) {
+            putCharXY(1, 0, ' ', buttonColor);
+            putStringXY(2, 0, mnemonic.getRawLabel(), buttonColor);
+            putCharXY(getWidth() - 1, 0, ' ', buttonColor);
+        } else {
+            putCharXY(0, 0, ' ', buttonColor);
+            putStringXY(1, 0, mnemonic.getRawLabel(), buttonColor);
+            putCharXY(getWidth() - 2, 0, ' ', buttonColor);
+
+            if (shadowColor != null) {
+                putCharXY(getWidth() - 1, 0,
+                    GraphicsChars.CP437[0xDC], shadowColor);
+                hLineXY(1, 1, getWidth() - 1,
+                    GraphicsChars.CP437[0xDF], shadowColor);
+            }
+        }
+        if (mnemonic.getScreenShortcutIdx() >= 0) {
+            if (inButtonPress) {
+                putCharXY(2 + mnemonic.getScreenShortcutIdx(), 0,
+                    mnemonic.getShortcut(), menuMnemonicColor);
+            } else {
+                putCharXY(1 + mnemonic.getScreenShortcutIdx(), 0,
+                    mnemonic.getShortcut(), menuMnemonicColor);
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TButton ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the mnemonic string for this button.
+     *
+     * @return mnemonic string
+     */
+    public MnemonicString getMnemonic() {
+        return mnemonic;
+    }
+
+    /**
+     * Act as though the button was pressed.  This is useful for other UI
+     * elements to get the same action as if the user clicked the button.
+     */
+    public void dispatch() {
+        if (action != null) {
+            action.DO(this);
+            inButtonPress = false;
+        }
+    }
+
+    /**
+     * Set the background color used for the button "shadow".  If null, no
+     * shadow will be drawn.
+     *
+     * @param color the new background color, or null for no shadow
+     */
+    public void setShadowColor(final CellAttributes color) {
+        if (color != null) {
+            shadowColor = new CellAttributes();
+            shadowColor.setTo(color);
+            shadowColor.setForeColor(Color.BLACK);
+            shadowColor.setBold(false);
+        } else {
+            shadowColor = null;
+        }
+    }
+
+}
diff --git a/src/jexer/TCalendar.java b/src/jexer/TCalendar.java
new file mode 100644 (file)
index 0000000..c2005cc
--- /dev/null
@@ -0,0 +1,324 @@
+/*
+ * 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.Calendar;
+import java.util.GregorianCalendar;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TCalendar is a date picker widget.
+ */
+public class TCalendar extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The calendar being displayed.
+     */
+    private GregorianCalendar displayCalendar = new GregorianCalendar();
+
+    /**
+     * The calendar with the selected day.
+     */
+    private GregorianCalendar calendar = new GregorianCalendar();
+
+    /**
+     * The action to perform when the user changes the value of the calendar.
+     */
+    private TAction updateAction = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param updateAction action to call when the user changes the value of
+     * the calendar
+     */
+    public TCalendar(final TWidget parent, final int x, final int y,
+        final TAction updateAction) {
+
+        // Set parent and window
+        super(parent, x, y, 28, 8);
+
+        this.updateAction = updateAction;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the left arrow.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse is currently on the left arrow
+     */
+    private boolean mouseOnLeftArrow(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() == 1)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the right arrow.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse is currently on the right arrow
+     */
+    private boolean mouseOnRightArrow(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() == getWidth() - 2)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse down clicks.
+     *
+     * @param mouse mouse button down event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if ((mouseOnLeftArrow(mouse)) && (mouse.isMouse1())) {
+            displayCalendar.add(Calendar.MONTH, -1);
+        } else if ((mouseOnRightArrow(mouse)) && (mouse.isMouse1())) {
+            displayCalendar.add(Calendar.MONTH, 1);
+        } else if (mouse.isMouse1()) {
+            // Find the day this might correspond to, and set it.
+            int index = (mouse.getY() - 2) * 7 + (mouse.getX() / 4) + 1;
+            // System.err.println("index: " + index);
+
+            int lastDayNumber = displayCalendar.getActualMaximum(
+                    Calendar.DAY_OF_MONTH);
+            GregorianCalendar firstOfMonth = new GregorianCalendar();
+            firstOfMonth.setTimeInMillis(displayCalendar.getTimeInMillis());
+            firstOfMonth.set(Calendar.DAY_OF_MONTH, 1);
+            int dayOf1st = firstOfMonth.get(Calendar.DAY_OF_WEEK) - 1;
+            // System.err.println("dayOf1st: " + dayOf1st);
+
+            int day = index - dayOf1st;
+            // System.err.println("day: " + day);
+
+            if ((day < 1) || (day > lastDayNumber)) {
+                return;
+            }
+            calendar.setTimeInMillis(displayCalendar.getTimeInMillis());
+            calendar.set(Calendar.DAY_OF_MONTH, day);
+        }
+    }
+
+    /**
+     * Handle mouse double click.
+     *
+     * @param mouse mouse double click event
+     */
+    @Override
+    public void onMouseDoubleClick(final TMouseEvent mouse) {
+        if (updateAction != null) {
+            updateAction.DO(this);
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        int increment = 0;
+
+        if (keypress.equals(kbUp)) {
+            increment = -7;
+        } else if (keypress.equals(kbDown)) {
+            increment = 7;
+        } else if (keypress.equals(kbLeft)) {
+            increment = -1;
+        } else if (keypress.equals(kbRight)) {
+            increment = 1;
+        } else if (keypress.equals(kbEnter)) {
+            if (updateAction != null) {
+                updateAction.DO(this);
+            }
+            return;
+        } else {
+            // Pass to parent for the things we don't care about.
+            super.onKeypress(keypress);
+            return;
+        }
+
+        if (increment != 0) {
+            calendar.add(Calendar.DAY_OF_YEAR, increment);
+
+            if ((displayCalendar.get(Calendar.MONTH) != calendar.get(
+                    Calendar.MONTH))
+                || (displayCalendar.get(Calendar.YEAR) != calendar.get(
+                    Calendar.YEAR))
+            ) {
+                if (increment < 0) {
+                    displayCalendar.add(Calendar.MONTH, -1);
+                } else {
+                    displayCalendar.add(Calendar.MONTH, 1);
+                }
+            }
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the combobox down arrow.
+     */
+    @Override
+    public void draw() {
+        CellAttributes backgroundColor = getTheme().getColor(
+                "tcalendar.background");
+        CellAttributes dayColor = getTheme().getColor(
+                "tcalendar.day");
+        CellAttributes selectedDayColor = getTheme().getColor(
+                "tcalendar.day.selected");
+        CellAttributes arrowColor = getTheme().getColor(
+                "tcalendar.arrow");
+        CellAttributes titleColor = getTheme().getColor(
+                "tcalendar.title");
+
+        // Fill in the interior background
+        for (int i = 0; i < getHeight(); i++) {
+            hLineXY(0, i, getWidth(), ' ', backgroundColor);
+        }
+
+        // Draw the title
+        String title = String.format("%tB %tY", displayCalendar,
+            displayCalendar);
+        // This particular title is always single-width (see format string
+        // above), but for completeness let's treat it the same as every
+        // other window title string.
+        int titleLeft = (getWidth() - StringUtils.width(title) - 2) / 2;
+        putCharXY(titleLeft, 0, ' ', titleColor);
+        putStringXY(titleLeft + 1, 0, title, titleColor);
+        putCharXY(titleLeft + StringUtils.width(title) + 1, 0, ' ',
+            titleColor);
+
+        // Arrows
+        putCharXY(1, 0, GraphicsChars.LEFTARROW, arrowColor);
+        putCharXY(getWidth() - 2, 0, GraphicsChars.RIGHTARROW,
+            arrowColor);
+
+        /*
+         * Now draw out the days.
+         */
+        putStringXY(0, 1, "  S   M   T   W   T   F   S ", dayColor);
+        int lastDayNumber = displayCalendar.getActualMaximum(
+                Calendar.DAY_OF_MONTH);
+        GregorianCalendar firstOfMonth = new GregorianCalendar();
+        firstOfMonth.setTimeInMillis(displayCalendar.getTimeInMillis());
+        firstOfMonth.set(Calendar.DAY_OF_MONTH, 1);
+        int dayOf1st = firstOfMonth.get(Calendar.DAY_OF_WEEK) - 1;
+        int dayColumn = dayOf1st * 4;
+        int row = 2;
+
+        int dayOfMonth = 1;
+        while (dayOfMonth <= lastDayNumber) {
+            if (dayColumn == 4 * 7) {
+                dayColumn = 0;
+                row++;
+            }
+            if ((dayOfMonth == calendar.get(Calendar.DAY_OF_MONTH))
+                && (displayCalendar.get(Calendar.MONTH) == calendar.get(
+                    Calendar.MONTH))
+                && (displayCalendar.get(Calendar.YEAR) == calendar.get(
+                    Calendar.YEAR))
+            ) {
+                putStringXY(dayColumn, row,
+                    String.format(" %2d ", dayOfMonth), selectedDayColor);
+            } else {
+                putStringXY(dayColumn, row,
+                    String.format(" %2d ", dayOfMonth), dayColor);
+            }
+            dayColumn += 4;
+            dayOfMonth++;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TCalendar --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get calendar value.
+     *
+     * @return the current calendar value (clone instance)
+     */
+    public Calendar getValue() {
+        return (Calendar) calendar.clone();
+    }
+
+    /**
+     * Set calendar value.
+     *
+     * @param calendar the new value to use
+     */
+    public final void setValue(final Calendar calendar) {
+        this.calendar.setTimeInMillis(calendar.getTimeInMillis());
+    }
+
+    /**
+     * Set calendar value.
+     *
+     * @param millis the millis to set to
+     */
+    public final void setValue(final long millis) {
+        this.calendar.setTimeInMillis(millis);
+    }
+
+}
diff --git a/src/jexer/TCheckBox.java b/src/jexer/TCheckBox.java
new file mode 100644 (file)
index 0000000..1f9a351
--- /dev/null
@@ -0,0 +1,216 @@
+/*
+ * 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 static jexer.TKeypress.kbEnter;
+import static jexer.TKeypress.kbEsc;
+import static jexer.TKeypress.kbSpace;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.MnemonicString;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+
+/**
+ * TCheckBox implements an on/off checkbox.
+ */
+public class TCheckBox extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * CheckBox state, true means checked.
+     */
+    private boolean checked = false;
+
+    /**
+     * The shortcut and checkbox label.
+     */
+    private MnemonicString mnemonic;
+
+    /**
+     * If true, use the window's background color.
+     */
+    private boolean useWindowBackground = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label label to display next to (right of) the checkbox
+     * @param checked initial check state
+     */
+    public TCheckBox(final TWidget parent, final int x, final int y,
+        final String label, final boolean checked) {
+
+        // Set parent and window
+        super(parent, x, y, StringUtils.width(label) + 4, 1);
+
+        mnemonic = new MnemonicString(label);
+        this.checked = checked;
+
+        setCursorVisible(true);
+        setCursorX(1);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the checkbox.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse is currently on the checkbox
+     */
+    private boolean mouseOnCheckBox(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() >= 0)
+            && (mouse.getX() <= 2)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse checkbox presses.
+     *
+     * @param mouse mouse button down event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if ((mouseOnCheckBox(mouse)) && (mouse.isMouse1())) {
+            // Switch state
+            checked = !checked;
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbSpace)
+            || keypress.equals(kbEnter)
+        ) {
+            checked = !checked;
+            return;
+        }
+
+        if (keypress.equals(kbEsc)) {
+            checked = false;
+            return;
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw a checkbox with label.
+     */
+    @Override
+    public void draw() {
+        CellAttributes checkboxColor;
+        CellAttributes mnemonicColor;
+
+        if (isAbsoluteActive()) {
+            checkboxColor = getTheme().getColor("tcheckbox.active");
+            mnemonicColor = getTheme().getColor("tcheckbox.mnemonic.highlighted");
+        } else {
+            checkboxColor = getTheme().getColor("tcheckbox.inactive");
+            mnemonicColor = getTheme().getColor("tcheckbox.mnemonic");
+        }
+        if (useWindowBackground) {
+            CellAttributes background = getWindow().getBackground();
+            checkboxColor.setBackColor(background.getBackColor());
+        }
+
+        putCharXY(0, 0, '[', checkboxColor);
+        if (checked) {
+            putCharXY(1, 0, GraphicsChars.CHECK, checkboxColor);
+        } else {
+            putCharXY(1, 0, ' ', checkboxColor);
+        }
+        putCharXY(2, 0, ']', checkboxColor);
+        putStringXY(4, 0, mnemonic.getRawLabel(), checkboxColor);
+        if (mnemonic.getScreenShortcutIdx() >= 0) {
+            putCharXY(4 + mnemonic.getScreenShortcutIdx(), 0,
+                mnemonic.getShortcut(), mnemonicColor);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TCheckBox --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get checked value.
+     *
+     * @return if true, this is checked
+     */
+    public boolean isChecked() {
+        return checked;
+    }
+
+    /**
+     * Set checked value.
+     *
+     * @param checked new checked value.
+     */
+    public void setChecked(final boolean checked) {
+        this.checked = checked;
+    }
+
+    /**
+     * Get the mnemonic string for this checkbox.
+     *
+     * @return mnemonic string
+     */
+    public MnemonicString getMnemonic() {
+        return mnemonic;
+    }
+
+}
diff --git a/src/jexer/TComboBox.java b/src/jexer/TComboBox.java
new file mode 100644 (file)
index 0000000..1164e6c
--- /dev/null
@@ -0,0 +1,465 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.event.TResizeEvent.Type;
+import static jexer.TKeypress.*;
+
+/**
+ * TComboBox implements a combobox containing a drop-down list and edit
+ * field.  Alt-Down can be used to show the drop-down.
+ */
+public class TComboBox extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The list of items in the drop-down.
+     */
+    private TList list;
+
+    /**
+     * The edit field containing the value to return.
+     */
+    private TField field;
+
+    /**
+     * The action to perform when the user selects an item (clicks or enter).
+     */
+    private TAction updateAction = null;
+
+    /**
+     * If true, the field cannot be updated to a value not on the list.
+     */
+    private boolean limitToListValue = true;
+    
+    /**
+     * The height of the list of values when it is shown, or -1 to use the 
+     * number of values in the list as the height.
+     */
+    private int valuesHeight = -1;
+    
+    /**
+     * The values shown by the drop-down list.
+     */
+    private List<String> values = new ArrayList<String>();
+    
+    /**
+     * When looking for a link between the displayed text and the list 
+     * of values, do a case sensitive search.
+     */
+    private boolean caseSensitive = true;
+
+    /**
+     * The maximum height of the values drop-down when it is visible.
+     */
+    private int maxValuesHeight = 3;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible combobox width, including the down-arrow
+     * @param values the possible values for the box, shown in the drop-down
+     * @param valuesIndex the initial index in values, or -1 for no default
+     * value
+     * @param valuesHeight the height of the values drop-down when it is
+     * visible, or -1 to use the number of values as the height of the list
+     * @param updateAction action to call when a new value is selected from
+     * the list or enter is pressed in the edit field
+     */
+    public TComboBox(final TWidget parent, final int x, final int y,
+        final int width, final List<String> values, final int valuesIndex,
+        final int valuesHeight, final TAction updateAction) {
+
+        // Set parent and window
+        super(parent, x, y, width, 1);
+
+        assert (values != null);
+
+        this.updateAction = updateAction;
+        this.values = values;
+        this.valuesHeight = valuesHeight;
+
+        field = new TField(this, 0, 0, Math.max(0, width - 3), false, "",
+            updateAction, null);
+        if (valuesIndex >= 0) {
+            field.setText(values.get(valuesIndex));
+        }
+
+        setHeight(1);
+        if (limitToListValue) {
+            field.setEnabled(false);
+        } else {
+            activate(field);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the down arrow.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse is currently on the down arrow
+     */
+    private boolean mouseOnArrow(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() >= getWidth() - 3)
+            && (mouse.getX() <= getWidth() - 1)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse down clicks.
+     *
+     * @param mouse mouse button down event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if ((mouseOnArrow(mouse)) && (mouse.isMouse1())) {
+            // Make the list visible or not.
+            if (list != null) {
+                hideDropdown();
+            } else {
+                displayDropdown();
+            }
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbEsc)) {
+            if (list != null) {
+                hideDropdown();
+                return;
+            }
+        }
+
+        if (keypress.equals(kbAltDown)) {
+            displayDropdown();
+            return;
+        }
+
+        if (keypress.equals(kbTab)
+            || (keypress.equals(kbShiftTab))
+            || (keypress.equals(kbBackTab))
+        ) {
+            if (list != null) {
+                hideDropdown();
+                return;
+            }
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's width: we need to set child widget widths.
+     *
+     * @param width new widget width
+     */
+    @Override
+    public void setWidth(final int width) {
+        if (field != null) {
+            field.setWidth(width - 3);
+        }
+        if (list != null) {
+            list.setWidth(width);
+        }
+        super.setWidth(width);
+    }
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw the combobox down arrow.
+     */
+    @Override
+    public void draw() {
+        CellAttributes comboBoxColor;
+
+        if (!isAbsoluteActive()) {
+            // We lost focus, turn off the list.
+            hideDropdown();
+        }
+
+        if (isAbsoluteActive()) {
+            comboBoxColor = getTheme().getColor("tcombobox.active");
+        } else {
+            comboBoxColor = getTheme().getColor("tcombobox.inactive");
+        }
+
+        putCharXY(getWidth() - 3, 0, GraphicsChars.DOWNARROWLEFT,
+            comboBoxColor);
+        putCharXY(getWidth() - 2, 0, GraphicsChars.DOWNARROW,
+            comboBoxColor);
+        putCharXY(getWidth() - 1, 0, GraphicsChars.DOWNARROWRIGHT,
+            comboBoxColor);
+    }
+
+    // ------------------------------------------------------------------------
+    // TComboBox --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hide the drop-down list.
+     */
+    public void hideList() {
+        list.setEnabled(false);
+        list.setVisible(false);
+        super.setHeight(1);
+        if (limitToListValue == false) {
+            activate(field);
+        }
+    }
+
+    /**
+     * Show the drop-down list.
+     */
+    public void showList() {
+        list.setEnabled(true);
+        list.setVisible(true);
+        super.setHeight(list.getHeight() + 1);
+        activate(list);
+    }
+
+    /**
+     * Get combobox text value.
+     *
+     * @return text in the edit field
+     */
+    public String getText() {
+        return field.getText();
+    }
+
+    /**
+     * Set combobox text value.
+     *
+     * @param text the new text in the edit field
+     */
+    public void setText(final String text) {
+        setText(text, true);
+    }
+
+    /**
+     * Set combobox text value.
+     *
+     * @param text the new text in the edit field
+     * @param caseSensitive if true, perform a case-sensitive search for the
+     * list item
+     */
+    public void setText(final String text, final boolean caseSensitive) {
+        this.caseSensitive = caseSensitive;
+       field.setText(text);
+        if (list != null) {
+               displayDropdown();
+        }
+    }
+
+    /**
+     * Set combobox text to one of the list values.
+     *
+     * @param index the index in the list
+     */
+    public void setIndex(final int index) {
+        list.setSelectedIndex(index);
+        field.setText(list.getSelected());
+    }
+
+    /**
+     * Get a copy of the list of strings to display.
+     *
+     * @return the list of strings
+     */
+    public final List<String> getList() {
+        return list.getList();
+    }
+
+    /**
+     * Set the new list of strings to display.
+     *
+     * @param list new list of strings
+     */
+    public final void setList(final List<String> list) {
+        this.list.setList(list);
+        this.list.setHeight(Math.max(3, Math.min(list.size() + 1,
+                    maxValuesHeight)));
+        field.setText("");
+    }
+    
+    /**
+     * Make sure the widget displays all its elements correctly according to
+     * the current size and content.
+     */
+    public void reflowData() {
+       // TODO: why setW/setH/reflow not enough for the scrollbars?
+       TList list = this.list;
+       if (list != null) {
+               int valuesHeight = this.valuesHeight;
+               if (valuesHeight < 0) {
+                       valuesHeight = values == null ? 0 : values.size() + 1;
+               }
+               
+               list.onResize(new TResizeEvent(Type.WIDGET, getWidth(), 
+                               valuesHeight));
+               setHeight(valuesHeight + 1);
+       }
+       
+       field.onResize(new TResizeEvent(Type.WIDGET, getWidth(), 
+                       field.getHeight()));
+    }
+    
+    @Override
+    public void onResize(TResizeEvent resize) {
+       super.onResize(resize);
+       reflowData();
+    }
+
+    /**
+     * Display the drop-down menu represented by {@link TComboBox#list}.
+     */
+    private void displayDropdown() {
+       if (this.list != null) {
+               hideDropdown();
+       }
+       
+       int valuesHeight = this.valuesHeight;
+       if (valuesHeight < 0) {
+               valuesHeight = values == null ? 0 : values.size() + 1;
+       }
+       
+       TList list = new TList(this, values, 0, 1, getWidth(), valuesHeight,
+                       new TAction() {
+                                       @Override
+                                       public void DO() {
+                                               TList list = TComboBox.this.list;
+                                               if (list == null) {
+                                                       return;
+                                               }
+                                               
+                                               field.setText(list.getSelected());
+                                               hideDropdown();
+                                               
+                                               if (updateAction != null) {
+                                                       updateAction.DO();
+                                               }
+                                       }
+                               }
+       );
+       
+       int i = -1;
+       if (values != null) {
+               String current = field.getText();
+               for (i = 0 ; i < values.size() ; i++) {
+                       String value = values.get(i);
+                       if ((caseSensitive && current.equals(value)) 
+                                       || (!caseSensitive && current.equalsIgnoreCase(value))) {
+                               break;
+                       }
+               }
+               
+               if (i >= values.size()) {
+                       i = -1;
+               }
+       }
+       list.setSelectedIndex(i);
+       
+       list.setEnabled(true);
+       list.setVisible(true);
+       
+       this.list = list;
+       
+       reflowData();
+       activate(list);
+    }
+    
+    /**
+     * Hide the drop-down menu represented by {@link TComboBox#list}.
+     */
+    private void hideDropdown() {
+       TList list = this.list;
+       
+       if (list != null) {
+               list.setEnabled(false);
+               list.setVisible(false);
+               removeChild(list);
+               
+               setHeight(1);
+               if (limitToListValue == false) {
+                activate(field);
+            }
+               
+               this.list = null;
+       }
+    }
+}
diff --git a/src/jexer/TCommand.java b/src/jexer/TCommand.java
new file mode 100644 (file)
index 0000000..874a29d
--- /dev/null
@@ -0,0 +1,227 @@
+/*
+ * 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;
+
+/**
+ * This class encapsulates a user command event.  User commands can be
+ * generated by menu actions, keyboard accelerators, and other UI elements.
+ * Commands can operate on both the application and individual widgets.
+ */
+public class TCommand {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Immediately abort the application (e.g. remote side closed
+     * connection).
+     */
+    public static final int ABORT               = 1;
+
+    /**
+     * File open dialog.
+     */
+    public static final int OPEN                = 2;
+
+    /**
+     * Exit application.
+     */
+    public static final int EXIT                = 3;
+
+    /**
+     * Spawn OS shell window.
+     */
+    public static final int SHELL               = 4;
+
+    /**
+     * Cut selected text and copy to the clipboard.
+     */
+    public static final int CUT                 = 5;
+
+    /**
+     * Copy selected text to clipboard.
+     */
+    public static final int COPY                = 6;
+
+    /**
+     * Paste from clipboard.
+     */
+    public static final int PASTE               = 7;
+
+    /**
+     * Clear selected text without copying it to the clipboard.
+     */
+    public static final int CLEAR               = 8;
+
+    /**
+     * Tile windows.
+     */
+    public static final int TILE                = 9;
+
+    /**
+     * Cascade windows.
+     */
+    public static final int CASCADE             = 10;
+
+    /**
+     * Close all windows.
+     */
+    public static final int CLOSE_ALL           = 11;
+
+    /**
+     * Move (move/resize) window.
+     */
+    public static final int WINDOW_MOVE         = 12;
+
+    /**
+     * Zoom (maximize/restore) window.
+     */
+    public static final int WINDOW_ZOOM         = 13;
+
+    /**
+     * Next window (like Alt-TAB).
+     */
+    public static final int WINDOW_NEXT         = 14;
+
+    /**
+     * Previous window (like Shift-Alt-TAB).
+     */
+    public static final int WINDOW_PREVIOUS     = 15;
+
+    /**
+     * Close window.
+     */
+    public static final int WINDOW_CLOSE        = 16;
+
+    /**
+     * Enter help system.
+     */
+    public static final int HELP                = 20;
+
+    /**
+     * Enter first menu.
+     */
+    public static final int MENU                = 21;
+
+    /**
+     * Save file.
+     */
+    public static final int SAVE                = 30;
+
+    /**
+     * Backend disconnected.
+     */
+    public static final int BACKEND_DISCONNECT  = 100;
+
+    public static final TCommand cmAbort        = new TCommand(ABORT);
+    public static final TCommand cmExit         = new TCommand(EXIT);
+    public static final TCommand cmQuit         = new TCommand(EXIT);
+    public static final TCommand cmOpen         = new TCommand(OPEN);
+    public static final TCommand cmShell        = new TCommand(SHELL);
+    public static final TCommand cmCut          = new TCommand(CUT);
+    public static final TCommand cmCopy         = new TCommand(COPY);
+    public static final TCommand cmPaste        = new TCommand(PASTE);
+    public static final TCommand cmClear        = new TCommand(CLEAR);
+    public static final TCommand cmTile         = new TCommand(TILE);
+    public static final TCommand cmCascade      = new TCommand(CASCADE);
+    public static final TCommand cmCloseAll     = new TCommand(CLOSE_ALL);
+    public static final TCommand cmWindowMove   = new TCommand(WINDOW_MOVE);
+    public static final TCommand cmWindowZoom   = new TCommand(WINDOW_ZOOM);
+    public static final TCommand cmWindowNext   = new TCommand(WINDOW_NEXT);
+    public static final TCommand cmWindowPrevious = new TCommand(WINDOW_PREVIOUS);
+    public static final TCommand cmWindowClose  = new TCommand(WINDOW_CLOSE);
+    public static final TCommand cmHelp         = new TCommand(HELP);
+    public static final TCommand cmSave         = new TCommand(SAVE);
+    public static final TCommand cmMenu         = new TCommand(MENU);
+    public static final TCommand cmBackendDisconnect    = new TCommand(BACKEND_DISCONNECT);
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Type of command, one of EXIT, CASCADE, etc.
+     */
+    private int type;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param type the Type of command, one of EXIT, CASCADE, etc.
+     */
+    public TCommand(final int type) {
+        this.type = type;
+    }
+
+    // ------------------------------------------------------------------------
+    // TCommand ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Make human-readable description of this TCommand.
+     *
+     * @return displayable String
+     */
+    @Override
+    public final String toString() {
+        return String.format("%s", type);
+    }
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another TCommand instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public final boolean equals(final Object rhs) {
+        if (!(rhs instanceof TCommand)) {
+            return false;
+        }
+
+        TCommand that = (TCommand) rhs;
+        return (type == that.type);
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        return type;
+    }
+
+}
diff --git a/src/jexer/TDesktop.java b/src/jexer/TDesktop.java
new file mode 100644 (file)
index 0000000..5aa52af
--- /dev/null
@@ -0,0 +1,258 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+/**
+ * TDesktop is a special-class window that is drawn underneath everything
+ * else.  Like a TWindow, it can contain widgets and perform "background"
+ * processing via onIdle().  But unlike a TWindow, it cannot be hidden,
+ * moved, or resized.
+ *
+ * <p>
+ * Events are passed to TDesktop as follows:
+ * <ul>
+ * <li>Mouse events are seen if they do not cover any other windows.</li>
+ * <li>Keypress events are seen if no other windows are open.</li>
+ * <li>Menu events are seen if no other windows are open.</li>
+ * <li>Command events are seen if no other windows are open.</li>
+ * </ul>
+ */
+public class TDesktop extends TWindow {
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent application
+     */
+    public TDesktop(final TApplication parent) {
+        super(parent, "", 0, 0, parent.getScreen().getWidth(),
+            parent.getDesktopBottom() - parent.getDesktopTop());
+
+        setActive(false);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        if (getChildren().size() == 1) {
+            TWidget child = getChildren().get(0);
+            if (!(child instanceof TWindow)) {
+                // Only one child, resize it to match my size.
+                child.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        getWidth(), getHeight()));
+            }
+        }
+        if (resize.getType() == TResizeEvent.Type.SCREEN) {
+            // Let children see the screen resize
+            for (TWidget widget: getChildren()) {
+                widget.onResize(resize);
+            }
+        }
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        // Pass to children
+        for (TWidget widget: getChildren()) {
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.handleEvent(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        // Pass to children
+        for (TWidget widget: getChildren()) {
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.handleEvent(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        // Default: do nothing, pass to children instead
+        super.onMouseMotion(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        // Default: do nothing, pass to children instead
+        super.onKeypress(keypress);
+    }
+
+    /**
+     * Handle posted menu events.
+     *
+     * @param menu menu event
+     */
+    @Override
+    public void onMenu(final TMenuEvent menu) {
+        // Default: do nothing, pass to children instead
+        super.onMenu(menu);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The default TDesktop draws a hatch character across everything.
+     */
+    @Override
+    public void draw() {
+        CellAttributes background = getTheme().getColor("tdesktop.background");
+        putAll(GraphicsChars.HATCH, background);
+
+        /*
+        // For debugging, let's see where the desktop bounds really are.
+        putCharXY(0, 0, '0', background);
+        putCharXY(getWidth() - 1, 0, '1', background);
+        putCharXY(0, getHeight() - 1, '2', background);
+        putCharXY(getWidth() - 1, getHeight() - 1, '3', background);
+         */
+    }
+
+    /**
+     * Hide window.  This is a NOP for TDesktop.
+     */
+    @Override
+    public final void hide() {}
+
+    /**
+     * Show window.  This is a NOP for TDesktop.
+     */
+    @Override
+    public final void show() {}
+
+    /**
+     * Called by hide().  This is a NOP for TDesktop.
+     */
+    @Override
+    public final void onHide() {}
+
+    /**
+     * Called by show().  This is a NOP for TDesktop.
+     */
+    @Override
+    public final void onShow() {}
+
+    /**
+     * Returns true if the mouse is currently on the close button.
+     *
+     * @return true if mouse is currently on the close button
+     */
+    @Override
+    protected final boolean mouseOnClose() {
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the maximize/restore button.
+     *
+     * @return true if the mouse is currently on the maximize/restore button
+     */
+    @Override
+    protected final boolean mouseOnMaximize() {
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the resizable lower right
+     * corner.
+     *
+     * @return true if the mouse is currently on the resizable lower right
+     * corner
+     */
+    @Override
+    protected final boolean mouseOnResize() {
+        return false;
+    }
+
+}
diff --git a/src/jexer/TDirectoryList.java b/src/jexer/TDirectoryList.java
new file mode 100644 (file)
index 0000000..322ff5c
--- /dev/null
@@ -0,0 +1,234 @@
+/*
+ * 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.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jexer.bits.StringUtils;
+
+/**
+ * TDirectoryList shows the files within a directory.
+ */
+public class TDirectoryList extends TList {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Files in the directory.
+     */
+    private Map<String, File> files;
+
+    /**
+     * Root path containing files to display.
+     */
+    private File path;
+
+    /**
+     * The list of filters that a file must match in order to be displayed.
+     */
+    private List<String> filters;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param path directory path, must be a directory
+     * @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 TDirectoryList(final TWidget parent, final String path, final int x,
+        final int y, final int width, final int height) {
+
+        this(parent, path, x, y, width, height, null, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param action action to perform when an item is selected (enter or
+     * double-click)
+     */
+    public TDirectoryList(final TWidget parent, final String path, final int x,
+        final int y, final int width, final int height, final TAction action) {
+
+        this(parent, path, x, y, width, height, action, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param action action to perform when an item is selected (enter or
+     * double-click)
+     * @param singleClickAction action to perform when an item is selected
+     * (single-click)
+     */
+    public TDirectoryList(final TWidget parent, final String path, final int x,
+        final int y, final int width, final int height, final TAction action,
+        final TAction singleClickAction) {
+
+        this(parent, path, x, y, width, height, action, singleClickAction,
+            null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param action action to perform when an item is selected (enter or
+     * double-click)
+     * @param singleClickAction action to perform when an item is selected
+     * (single-click)
+     * @param filters a list of strings that files must match to be displayed
+     */
+    public TDirectoryList(final TWidget parent, final String path, final int x,
+        final int y, final int width, final int height, final TAction action,
+        final TAction singleClickAction, final List<String> filters) {
+
+        super(parent, null, x, y, width, height, action);
+        files = new HashMap<String, File>();
+        this.filters = filters;
+        this.singleClickAction = singleClickAction;
+
+        setPath(path);
+    }
+
+    // ------------------------------------------------------------------------
+    // TList ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // TDirectoryList ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the new path to display.
+     *
+     * @param path new path to list files for
+     */
+    public void setPath(final String path) {
+        this.path = new File(path);
+
+        List<String> newStrings = new ArrayList<String>();
+        files.clear();
+
+        // Build a list of files in this directory
+        File [] newFiles = this.path.listFiles();
+        if (newFiles != null) {
+            for (int i = 0; i < newFiles.length; i++) {
+                if (newFiles[i].getName().startsWith(".")) {
+                    continue;
+                }
+                if (newFiles[i].isDirectory()) {
+                    continue;
+                }
+                if (filters != null) {
+                    for (String pattern: filters) {
+
+                        /*
+                        System.err.println("newFiles[i] " +
+                            newFiles[i].getName() + " " + pattern +
+                            " " + newFiles[i].getName().matches(pattern));
+                        */
+
+                        if (newFiles[i].getName().matches(pattern)) {
+                            String key = renderFile(newFiles[i]);
+                            files.put(key, newFiles[i]);
+                            newStrings.add(key);
+                            break;
+                        }
+                    }
+                } else {
+                    String key = renderFile(newFiles[i]);
+                    files.put(key, newFiles[i]);
+                    newStrings.add(key);
+                }
+            }
+        }
+        setList(newStrings);
+
+        // Select the first entry
+        if (getMaxSelectedIndex() >= 0) {
+            setSelectedIndex(0);
+        }
+    }
+
+    /**
+     * Get the path that is being displayed.
+     *
+     * @return the path
+     */
+    public File getPath() {
+        path = files.get(getSelected());
+        return path;
+    }
+
+    /**
+     * Format one of the entries for drawing on the screen.
+     *
+     * @param file the File
+     * @return the line to draw
+     */
+    private String renderFile(final File file) {
+        String name = file.getName();
+        if (StringUtils.width(name) > 20) {
+            name = name.substring(0, 17) + "...";
+        }
+        return String.format("%-20s %5dk", name, (file.length() / 1024));
+    }
+
+}
diff --git a/src/jexer/TEditColorThemeWindow.java b/src/jexer/TEditColorThemeWindow.java
new file mode 100644 (file)
index 0000000..668309d
--- /dev/null
@@ -0,0 +1,789 @@
+/*
+ * 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.List;
+import java.util.ResourceBundle;
+
+import jexer.bits.Color;
+import jexer.bits.ColorTheme;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TEditColorThemeWindow provides an easy UI for users to alter the running
+ * color theme.
+ *
+ */
+public class TEditColorThemeWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TEditColorThemeWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The current editing theme.
+     */
+    private ColorTheme editTheme;
+
+    /**
+     * The left-side list of colors pane.
+     */
+    private TList colorNames;
+
+    /**
+     * The foreground color.
+     */
+    private ForegroundPicker foreground;
+
+    /**
+     * The background color.
+     */
+    private BackgroundPicker background;
+
+    /**
+     * The foreground color picker.
+     */
+    class ForegroundPicker extends TWidget {
+
+        /**
+         * The selected color.
+         */
+        Color color;
+
+        /**
+         * The bold flag.
+         */
+        boolean bold;
+
+        /**
+         * Public constructor.
+         *
+         * @param parent parent widget
+         * @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 ForegroundPicker(final TWidget parent, final int x,
+            final int y, final int width, final int height) {
+
+            super(parent, x, y, width, height);
+        }
+
+        /**
+         * Get the X grid coordinate for this color.
+         *
+         * @param color the Color value
+         * @return the X coordinate
+         */
+        private int getXColorPosition(final Color color) {
+            if (color.equals(Color.BLACK)) {
+                return 2;
+            } else if (color.equals(Color.BLUE)) {
+                return 5;
+            } else if (color.equals(Color.GREEN)) {
+                return 8;
+            } else if (color.equals(Color.CYAN)) {
+                return 11;
+            } else if (color.equals(Color.RED)) {
+                return 2;
+            } else if (color.equals(Color.MAGENTA)) {
+                return 5;
+            } else if (color.equals(Color.YELLOW)) {
+                return 8;
+            } else if (color.equals(Color.WHITE)) {
+                return 11;
+            }
+            throw new IllegalArgumentException("Invalid color: " + color);
+        }
+
+        /**
+         * Get the Y grid coordinate for this color.
+         *
+         * @param color the Color value
+         * @param bold if true use bold color
+         * @return the Y coordinate
+         */
+        private int getYColorPosition(final Color color, final boolean bold) {
+            int dotY = 1;
+            if (color.equals(Color.RED)) {
+                dotY = 2;
+            } else if (color.equals(Color.MAGENTA)) {
+                dotY = 2;
+            } else if (color.equals(Color.YELLOW)) {
+                dotY = 2;
+            } else if (color.equals(Color.WHITE)) {
+                dotY = 2;
+            }
+            if (bold) {
+                dotY += 2;
+            }
+            return dotY;
+        }
+
+        /**
+         * Get the bold value based on Y grid coordinate.
+         *
+         * @param dotY the Y coordinate
+         * @return the bold value
+         */
+        private boolean getBoldFromPosition(final int dotY) {
+            if (dotY > 2) {
+                return true;
+            }
+            return false;
+        }
+
+        /**
+         * Get the color based on (X, Y) grid coordinate.
+         *
+         * @param dotX the X coordinate
+         * @param dotY the Y coordinate
+         * @return the Color value
+         */
+        private Color getColorFromPosition(final int dotX, final int dotY) {
+            int y = dotY;
+            if (y > 2) {
+                y -= 2;
+            }
+            if ((1 <= dotX) && (dotX <= 3) && (y == 1)) {
+                return Color.BLACK;
+            }
+            if ((4 <= dotX) && (dotX <= 6) && (y == 1)) {
+                return Color.BLUE;
+            }
+            if ((7 <= dotX) && (dotX <= 9) && (y == 1)) {
+                return Color.GREEN;
+            }
+            if ((10 <= dotX) && (dotX <= 12) && (y == 1)) {
+                return Color.CYAN;
+            }
+            if ((1 <= dotX) && (dotX <= 3) && (y == 2)) {
+                return Color.RED;
+            }
+            if ((4 <= dotX) && (dotX <= 6) && (y == 2)) {
+                return Color.MAGENTA;
+            }
+            if ((7 <= dotX) && (dotX <= 9) && (y == 2)) {
+                return Color.YELLOW;
+            }
+            if ((10 <= dotX) && (dotX <= 12) && (y == 2)) {
+                return Color.WHITE;
+            }
+
+            throw new IllegalArgumentException("Invalid coordinates: "
+                + dotX + ", " + dotY);
+        }
+
+        /**
+         * Draw the foreground colors grid.
+         */
+        @Override
+        public void draw() {
+            CellAttributes border = getWindow().getBorder();
+            CellAttributes background = getWindow().getBackground();
+            CellAttributes attr = new CellAttributes();
+
+            drawBox(0, 0, getWidth(), getHeight(), border, background, 1,
+                false);
+
+            attr.setTo(getTheme().getColor("twindow.background.modal"));
+            if (isActive()) {
+                attr.setForeColor(getTheme().getColor("tlabel").getForeColor());
+                attr.setBold(getTheme().getColor("tlabel").isBold());
+            }
+            putStringXY(1, 0, i18n.getString("foregroundLabel"), attr);
+
+            // Have to draw the colors manually because the int value matches
+            // SGR, not CGA.
+            attr.reset();
+            attr.setForeColor(Color.BLACK);
+            putStringXY(1, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.BLUE);
+            putStringXY(4, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.GREEN);
+            putStringXY(7, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.CYAN);
+            putStringXY(10, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.RED);
+            putStringXY(1, 2, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.MAGENTA);
+            putStringXY(4, 2, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.YELLOW);
+            putStringXY(7, 2, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.WHITE);
+            putStringXY(10, 2, "\u2588\u2588\u2588", attr);
+
+            attr.setBold(true);
+            attr.setForeColor(Color.BLACK);
+            putStringXY(1, 3, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.BLUE);
+            putStringXY(4, 3, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.GREEN);
+            putStringXY(7, 3, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.CYAN);
+            putStringXY(10, 3, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.RED);
+            putStringXY(1, 4, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.MAGENTA);
+            putStringXY(4, 4, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.YELLOW);
+            putStringXY(7, 4, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.WHITE);
+            putStringXY(10, 4, "\u2588\u2588\u2588", attr);
+
+            // Draw the dot
+            int dotX = getXColorPosition(color);
+            int dotY = getYColorPosition(color, bold);
+            if (color.equals(Color.BLACK) && !bold) {
+                // Use white-on-black for black.  All other colors use
+                // black-on-whatever.
+                attr.reset();
+                putCharXY(dotX, dotY, GraphicsChars.CP437[0x07], attr);
+            } else {
+                attr.setForeColor(color);
+                attr.setBold(bold);
+                putCharXY(dotX, dotY, '\u25D8', attr);
+            }
+        }
+
+        /**
+         * Handle keystrokes.
+         *
+         * @param keypress keystroke event
+         */
+        @Override
+        public void onKeypress(final TKeypressEvent keypress) {
+            if (keypress.equals(kbRight)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color, bold);
+                if (dotX < 10) {
+                    dotX += 3;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else if (keypress.equals(kbLeft)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color, bold);
+                if (dotX > 3) {
+                    dotX -= 3;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else if (keypress.equals(kbUp)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color, bold);
+                if (dotY > 1) {
+                    dotY--;
+                }
+                color = getColorFromPosition(dotX, dotY);
+                bold = getBoldFromPosition(dotY);
+            } else if (keypress.equals(kbDown)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color, bold);
+                if (dotY < 4) {
+                    dotY++;
+                }
+                color = getColorFromPosition(dotX, dotY);
+                bold = getBoldFromPosition(dotY);
+            } else {
+                // Pass to my parent
+                super.onKeypress(keypress);
+                return;
+            }
+
+            // Save this update to the local theme.
+            ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+        }
+
+        /**
+         * Handle mouse press events.
+         *
+         * @param mouse mouse button press event
+         */
+        @Override
+        public void onMouseDown(final TMouseEvent mouse) {
+            if (mouse.isMouseWheelUp()) {
+                // Do this like kbUp
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color, bold);
+                if (dotY > 1) {
+                    dotY--;
+                }
+                color = getColorFromPosition(dotX, dotY);
+                bold = getBoldFromPosition(dotY);
+            } else if (mouse.isMouseWheelDown()) {
+                // Do this like kbDown
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color, bold);
+                if (dotY < 4) {
+                    dotY++;
+                }
+                color = getColorFromPosition(dotX, dotY);
+                bold = getBoldFromPosition(dotY);
+            } else if ((mouse.getX() > 0)
+                && (mouse.getX() < getWidth() - 1)
+                && (mouse.getY() > 0)
+                && (mouse.getY() < getHeight() - 1)
+            ) {
+                color = getColorFromPosition(mouse.getX(), mouse.getY());
+                bold = getBoldFromPosition(mouse.getY());
+            } else {
+                // Let parent class handle it.
+                super.onMouseDown(mouse);
+                return;
+            }
+
+            // Save this update to the local theme.
+            ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+        }
+
+    }
+
+    /**
+     * The background color picker.
+     */
+    class BackgroundPicker extends TWidget {
+
+        /**
+         * The selected color.
+         */
+        Color color;
+
+        /**
+         * Public constructor.
+         *
+         * @param parent parent widget
+         * @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 BackgroundPicker(final TWidget parent, final int x,
+            final int y, final int width, final int height) {
+
+            super(parent, x, y, width, height);
+        }
+
+        /**
+         * Get the X grid coordinate for this color.
+         *
+         * @param color the Color value
+         * @return the X coordinate
+         */
+        private int getXColorPosition(final Color color) {
+            if (color.equals(Color.BLACK)) {
+                return 2;
+            } else if (color.equals(Color.BLUE)) {
+                return 5;
+            } else if (color.equals(Color.GREEN)) {
+                return 8;
+            } else if (color.equals(Color.CYAN)) {
+                return 11;
+            } else if (color.equals(Color.RED)) {
+                return 2;
+            } else if (color.equals(Color.MAGENTA)) {
+                return 5;
+            } else if (color.equals(Color.YELLOW)) {
+                return 8;
+            } else if (color.equals(Color.WHITE)) {
+                return 11;
+            }
+            throw new IllegalArgumentException("Invalid color: " + color);
+        }
+
+        /**
+         * Get the Y grid coordinate for this color.
+         *
+         * @param color the Color value
+         * @return the Y coordinate
+         */
+        private int getYColorPosition(final Color color) {
+            int dotY = 1;
+            if (color.equals(Color.RED)) {
+                dotY = 2;
+            } else if (color.equals(Color.MAGENTA)) {
+                dotY = 2;
+            } else if (color.equals(Color.YELLOW)) {
+                dotY = 2;
+            } else if (color.equals(Color.WHITE)) {
+                dotY = 2;
+            }
+            return dotY;
+        }
+
+        /**
+         * Get the color based on (X, Y) grid coordinate.
+         *
+         * @param dotX the X coordinate
+         * @param dotY the Y coordinate
+         * @return the Color value
+         */
+        private Color getColorFromPosition(final int dotX, final int dotY) {
+            if ((1 <= dotX) && (dotX <= 3) && (dotY == 1)) {
+                return Color.BLACK;
+            }
+            if ((4 <= dotX) && (dotX <= 6) && (dotY == 1)) {
+                return Color.BLUE;
+            }
+            if ((7 <= dotX) && (dotX <= 9) && (dotY == 1)) {
+                return Color.GREEN;
+            }
+            if ((10 <= dotX) && (dotX <= 12) && (dotY == 1)) {
+                return Color.CYAN;
+            }
+            if ((1 <= dotX) && (dotX <= 3) && (dotY == 2)) {
+                return Color.RED;
+            }
+            if ((4 <= dotX) && (dotX <= 6) && (dotY == 2)) {
+                return Color.MAGENTA;
+            }
+            if ((7 <= dotX) && (dotX <= 9) && (dotY == 2)) {
+                return Color.YELLOW;
+            }
+            if ((10 <= dotX) && (dotX <= 12) && (dotY == 2)) {
+                return Color.WHITE;
+            }
+
+            throw new IllegalArgumentException("Invalid coordinates: "
+                + dotX + ", " + dotY);
+        }
+
+        /**
+         * Draw the background colors grid.
+         */
+        @Override
+        public void draw() {
+            CellAttributes border = getWindow().getBorder();
+            CellAttributes background = getWindow().getBackground();
+            CellAttributes attr = new CellAttributes();
+
+            drawBox(0, 0, getWidth(), getHeight(), border, background, 1,
+                false);
+
+            attr.setTo(getTheme().getColor("twindow.background.modal"));
+            if (isActive()) {
+                attr.setForeColor(getTheme().getColor("tlabel").getForeColor());
+                attr.setBold(getTheme().getColor("tlabel").isBold());
+            }
+            putStringXY(1, 0, i18n.getString("backgroundLabel"), attr);
+
+            // Have to draw the colors manually because the int value matches
+            // SGR, not CGA.
+            attr.reset();
+            attr.setForeColor(Color.BLACK);
+            putStringXY(1, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.BLUE);
+            putStringXY(4, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.GREEN);
+            putStringXY(7, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.CYAN);
+            putStringXY(10, 1, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.RED);
+            putStringXY(1, 2, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.MAGENTA);
+            putStringXY(4, 2, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.YELLOW);
+            putStringXY(7, 2, "\u2588\u2588\u2588", attr);
+            attr.setForeColor(Color.WHITE);
+            putStringXY(10, 2, "\u2588\u2588\u2588", attr);
+
+            // Draw the dot
+            int dotX = getXColorPosition(color);
+            int dotY = getYColorPosition(color);
+            if (color.equals(Color.BLACK)) {
+                // Use white-on-black for black.  All other colors use
+                // black-on-whatever.
+                attr.reset();
+                putCharXY(dotX, dotY, GraphicsChars.CP437[0x07], attr);
+            } else {
+                attr.setForeColor(color);
+                putCharXY(dotX, dotY, '\u25D8', attr);
+            }
+
+        }
+
+        /**
+         * Handle keystrokes.
+         *
+         * @param keypress keystroke event
+         */
+        @Override
+        public void onKeypress(final TKeypressEvent keypress) {
+            if (keypress.equals(kbRight)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color);
+                if (dotX < 10) {
+                    dotX += 3;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else if (keypress.equals(kbLeft)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color);
+                if (dotX > 3) {
+                    dotX -= 3;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else if (keypress.equals(kbUp)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color);
+                if (dotY == 2) {
+                    dotY--;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else if (keypress.equals(kbDown)) {
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color);
+                if (dotY == 1) {
+                    dotY++;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else {
+                // Pass to my parent
+                super.onKeypress(keypress);
+            }
+
+            // Save this update to the local theme.
+            ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+        }
+
+        /**
+         * Handle mouse press events.
+         *
+         * @param mouse mouse button press event
+         */
+        @Override
+        public void onMouseDown(final TMouseEvent mouse) {
+            if (mouse.isMouseWheelUp()) {
+                // Do this like kbUp
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color);
+                if (dotY == 2) {
+                    dotY--;
+                }
+                color = getColorFromPosition(dotX, dotY);
+            } else if (mouse.isMouseWheelDown()) {
+                // Do this like kbDown
+                int dotX = getXColorPosition(color);
+                int dotY = getYColorPosition(color);
+                if (dotY == 1) {
+                    dotY++;
+                }
+                color = getColorFromPosition(dotX, dotY);
+                return;
+            } else if ((mouse.getX() > 0)
+                && (mouse.getX() < getWidth() - 1)
+                && (mouse.getY() > 0)
+                && (mouse.getY() < getHeight() - 1)
+            ) {
+                color = getColorFromPosition(mouse.getX(), mouse.getY());
+            } else {
+                // Let parent class handle it.
+                super.onMouseDown(mouse);
+                return;
+            }
+
+            // Save this update to the local theme.
+            ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  The window will be centered on screen.
+     *
+     * @param application the TApplication that manages this window
+     */
+    public TEditColorThemeWindow(final TApplication application) {
+
+        // Register with the TApplication
+        super(application, i18n.getString("windowTitle"), 0, 0, 60, 18, MODAL);
+
+        // Initialize with the first color
+        List<String> colors = getTheme().getColorNames();
+        assert (colors.size() > 0);
+        editTheme = new ColorTheme();
+        for (String key: colors) {
+            CellAttributes attr = new CellAttributes();
+            attr.setTo(getTheme().getColor(key));
+            editTheme.setColor(key, attr);
+        }
+
+        colorNames = addList(colors, 2, 2, 38, getHeight() - 7,
+            new TAction() {
+                // When the user presses Enter
+                public void DO() {
+                    refreshFromTheme(colorNames.getSelected());
+                }
+            },
+            new TAction() {
+                // When the user navigates with keyboard
+                public void DO() {
+                    refreshFromTheme(colorNames.getSelected());
+                }
+            },
+            new TAction() {
+                // When the user navigates with keyboard
+                public void DO() {
+                    refreshFromTheme(colorNames.getSelected());
+                }
+            }
+        );
+        foreground = new ForegroundPicker(this, 42, 1, 14, 6);
+        background = new BackgroundPicker(this, 42, 7, 14, 4);
+        refreshFromTheme(colors.get(0));
+        colorNames.setSelectedIndex(0);
+
+        addButton(i18n.getString("okButton"), getWidth() - 37, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    ColorTheme global = getTheme();
+                    List<String> colors = editTheme.getColorNames();
+                    for (String key: colors) {
+                        CellAttributes attr = new CellAttributes();
+                        attr.setTo(editTheme.getColor(key));
+                        global.setColor(key, attr);
+                    }
+                    getApplication().closeWindow(TEditColorThemeWindow.this);
+                }
+            }
+        );
+
+        addButton(i18n.getString("cancelButton"), getWidth() - 25,
+            getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    getApplication().closeWindow(TEditColorThemeWindow.this);
+                }
+            }
+        );
+
+        // Default to the color list
+        activate(colorNames);
+
+        // Add shortcut text
+        newStatusBar(i18n.getString("statusBar"));
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        // Escape - behave like cancel
+        if (keypress.equals(kbEsc)) {
+            getApplication().closeWindow(this);
+            return;
+        }
+
+        // Pass to my parent
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw me on screen.
+     */
+    @Override
+    public void draw() {
+        super.draw();
+        CellAttributes attr = new CellAttributes();
+
+        // Draw the label on colorNames
+        attr.setTo(getTheme().getColor("twindow.background.modal"));
+        if (colorNames.isActive()) {
+            attr.setForeColor(getTheme().getColor("tlabel").getForeColor());
+            attr.setBold(getTheme().getColor("tlabel").isBold());
+        }
+        putStringXY(3, 2, i18n.getString("colorName"), attr);
+
+        // Draw the sample text box
+        attr.reset();
+        attr.setForeColor(foreground.color);
+        attr.setBold(foreground.bold);
+        attr.setBackColor(background.color);
+        putStringXY(getWidth() - 17, getHeight() - 6,
+            i18n.getString("textTextText"), attr);
+        putStringXY(getWidth() - 17, getHeight() - 5,
+            i18n.getString("textTextText"), attr);
+    }
+
+    // ------------------------------------------------------------------------
+    // TEditColorThemeWindow --------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set various widgets/values to the editing theme color.
+     *
+     * @param colorName name of color from theme
+     */
+    private void refreshFromTheme(final String colorName) {
+        CellAttributes attr = editTheme.getColor(colorName);
+        foreground.color = attr.getForeColor();
+        foreground.bold = attr.isBold();
+        background.color = attr.getBackColor();
+    }
+
+    /**
+     * Examines foreground, background, and colorNames and sets the color in
+     * editTheme.
+     */
+    private void saveToEditTheme() {
+        String colorName = colorNames.getSelected();
+        if (colorName == null) {
+            return;
+        }
+        CellAttributes attr = editTheme.getColor(colorName);
+        attr.setForeColor(foreground.color);
+        attr.setBold(foreground.bold);
+        attr.setBackColor(background.color);
+        editTheme.setColor(colorName, attr);
+    }
+
+}
diff --git a/src/jexer/TEditColorThemeWindow.properties b/src/jexer/TEditColorThemeWindow.properties
new file mode 100644 (file)
index 0000000..f4c6220
--- /dev/null
@@ -0,0 +1,8 @@
+foregroundLabel=\ Foreground\ 
+backgroundLabel=\ Background\ 
+windowTitle=Colors
+okButton=\ \ &OK\ \ 
+cancelButton=&Cancel
+statusBar=Select Colors
+colorName=Color Name
+textTextText=Text Text Text
diff --git a/src/jexer/TEditorWidget.java b/src/jexer/TEditorWidget.java
new file mode 100644 (file)
index 0000000..bea25ed
--- /dev/null
@@ -0,0 +1,1441 @@
+/*
+ * 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.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.teditor.Document;
+import jexer.teditor.Line;
+import jexer.teditor.Word;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TEditorWidget displays an editable text document.  It is unaware of
+ * scrolling behavior, but can respond to mouse and keyboard events.
+ */
+public class TEditorWidget extends TWidget implements EditMenuUser {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The number of lines to scroll on mouse wheel up/down.
+     */
+    private static final int wheelScrollSize = 3;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The document being edited.
+     */
+    protected Document document;
+
+    /**
+     * The default color for the editable text.
+     */
+    private CellAttributes defaultColor = null;
+
+    /**
+     * The topmost line number in the visible area.  0-based.
+     */
+    private int topLine = 0;
+
+    /**
+     * The leftmost column number in the visible area.  0-based.
+     */
+    private int leftColumn = 0;
+
+    /**
+     * If true, the mouse is dragging a selection.
+     */
+    private boolean inSelection = false;
+
+    /**
+     * Selection starting column.
+     */
+    private int selectionColumn0;
+
+    /**
+     * Selection starting line.
+     */
+    private int selectionLine0;
+
+    /**
+     * Selection ending column.
+     */
+    private int selectionColumn1;
+
+    /**
+     * Selection ending line.
+     */
+    private int selectionLine1;
+
+    /**
+     * The list of undo/redo states.
+     */
+    private List<SavedState> undoList = new ArrayList<SavedState>();
+
+    /**
+     * The position in undoList for undo/redo.
+     */
+    private int undoListI = 0;
+
+    /**
+     * The maximum size of the undo list.
+     */
+    private int undoLevel = 50;
+
+    /**
+     * The saved state for an undo/redo operation.
+     */
+    private class SavedState {
+        /**
+         * The Document state.
+         */
+        public Document document;
+
+        /**
+         * The topmost line number in the visible area.  0-based.
+         */
+        public int topLine = 0;
+
+        /**
+         * The leftmost column number in the visible area.  0-based.
+         */
+        public int leftColumn = 0;
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text text on the screen
+     * @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 TEditorWidget(final TWidget parent, final String text, final int x,
+        final int y, final int width, final int height) {
+
+        // Set parent and window
+        super(parent, x, y, width, height);
+
+        setCursorVisible(true);
+
+        defaultColor = getTheme().getColor("teditor");
+        document = new Document(text, defaultColor);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouse.isMouseWheelUp()) {
+            for (int i = 0; i < wheelScrollSize; i++) {
+                if (topLine > 0) {
+                    topLine--;
+                    alignDocument(false);
+                }
+            }
+            return;
+        }
+        if (mouse.isMouseWheelDown()) {
+            for (int i = 0; i < wheelScrollSize; i++) {
+                if (topLine < document.getLineCount() - 1) {
+                    topLine++;
+                    alignDocument(true);
+                }
+            }
+            return;
+        }
+
+        if (mouse.isMouse1()) {
+            // Selection.
+            int newLine = topLine + mouse.getY();
+            int newX = leftColumn + mouse.getX();
+
+            inSelection = true;
+            if (newLine > document.getLineCount() - 1) {
+                selectionLine0 = document.getLineCount() - 1;
+            } else {
+                selectionLine0 = topLine + mouse.getY();
+            }
+            selectionColumn0 = leftColumn + mouse.getX();
+            selectionColumn0 = Math.max(0, Math.min(selectionColumn0,
+                    document.getLine(selectionLine0).getDisplayLength() - 1));
+            selectionColumn1 = selectionColumn0;
+            selectionLine1 = selectionLine0;
+
+            // Set the row and column
+            if (newLine > document.getLineCount() - 1) {
+                // Go to the end
+                document.setLineNumber(document.getLineCount() - 1);
+                document.end();
+                if (newLine > document.getLineCount() - 1) {
+                    setCursorY(document.getLineCount() - 1 - topLine);
+                } else {
+                    setCursorY(mouse.getY());
+                }
+                alignCursor();
+                if (inSelection) {
+                    selectionColumn1 = document.getCursor();
+                    selectionLine1 = document.getLineNumber();
+                }
+                return;
+            }
+
+            document.setLineNumber(newLine);
+            setCursorY(mouse.getY());
+            if (newX >= document.getCurrentLine().getDisplayLength()) {
+                document.end();
+                alignCursor();
+            } else {
+                document.setCursor(newX);
+                setCursorX(mouse.getX());
+            }
+            if (inSelection) {
+                selectionColumn1 = document.getCursor();
+                selectionLine1 = document.getLineNumber();
+            }
+            return;
+        } else {
+            inSelection = false;
+        }
+
+        // Pass to children
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse motion events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+
+        if (mouse.isMouse1()) {
+            // Set the row and column
+            int newLine = topLine + mouse.getY();
+            int newX = leftColumn + mouse.getX();
+            if ((newLine < 0) || (newX < 0)) {
+                return;
+            }
+
+            // Selection.
+            if (inSelection) {
+                selectionColumn1 = newX;
+                selectionLine1 = newLine;
+            } else {
+                inSelection = true;
+                selectionColumn0 = newX;
+                selectionLine0 = newLine;
+                selectionColumn1 = selectionColumn0;
+                selectionLine1 = selectionLine0;
+            }
+
+            if (newLine > document.getLineCount() - 1) {
+                // Go to the end
+                document.setLineNumber(document.getLineCount() - 1);
+                document.end();
+                if (newLine > document.getLineCount() - 1) {
+                    setCursorY(document.getLineCount() - 1 - topLine);
+                } else {
+                    setCursorY(mouse.getY());
+                }
+                alignCursor();
+                if (inSelection) {
+                    selectionColumn1 = document.getCursor();
+                    selectionLine1 = document.getLineNumber();
+                }
+                return;
+            }
+            document.setLineNumber(newLine);
+            setCursorY(mouse.getY());
+            if (newX >= document.getCurrentLine().getDisplayLength()) {
+                document.end();
+                alignCursor();
+            } else {
+                document.setCursor(newX);
+                setCursorX(mouse.getX());
+            }
+            if (inSelection) {
+                selectionColumn1 = document.getCursor();
+                selectionLine1 = document.getLineNumber();
+            }
+            return;
+        }
+
+        // Pass to children
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.getKey().isShift()) {
+            if (keypress.equals(kbShiftLeft)
+                || keypress.equals(kbShiftRight)
+                || keypress.equals(kbShiftUp)
+                || keypress.equals(kbShiftDown)
+                || keypress.equals(kbShiftPgDn)
+                || keypress.equals(kbShiftPgUp)
+                || keypress.equals(kbShiftHome)
+                || keypress.equals(kbShiftEnd)
+            ) {
+                // Shifted navigation keys enable selection
+                if (!inSelection) {
+                    inSelection = true;
+                    selectionColumn0 = document.getCursor();
+                    selectionLine0 = document.getLineNumber();
+                    selectionColumn1 = selectionColumn0;
+                    selectionLine1 = selectionLine0;
+                }
+            }
+        } else {
+            if (keypress.equals(kbLeft)
+                || keypress.equals(kbRight)
+                || keypress.equals(kbUp)
+                || keypress.equals(kbDown)
+                || keypress.equals(kbPgDn)
+                || keypress.equals(kbPgUp)
+                || keypress.equals(kbHome)
+                || keypress.equals(kbEnd)
+            ) {
+                // 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)
+            || keypress.equals(kbShiftLeft)
+        ) {
+            document.left();
+            alignTopLine(false);
+        } else if (keypress.equals(kbRight)
+            || keypress.equals(kbShiftRight)
+        ) {
+            document.right();
+            alignTopLine(true);
+        } else if (keypress.equals(kbAltLeft)
+            || keypress.equals(kbCtrlLeft)
+            || keypress.equals(kbAltShiftLeft)
+            || keypress.equals(kbCtrlShiftLeft)
+        ) {
+            document.backwardsWord();
+            alignTopLine(false);
+        } else if (keypress.equals(kbAltRight)
+            || keypress.equals(kbCtrlRight)
+            || keypress.equals(kbAltShiftRight)
+            || keypress.equals(kbCtrlShiftRight)
+        ) {
+            document.forwardsWord();
+            alignTopLine(true);
+        } else if (keypress.equals(kbUp)
+            || keypress.equals(kbShiftUp)
+        ) {
+            document.up();
+            alignTopLine(false);
+        } else if (keypress.equals(kbDown)
+            || keypress.equals(kbShiftDown)
+        ) {
+            document.down();
+            alignTopLine(true);
+        } else if (keypress.equals(kbPgUp)
+            || keypress.equals(kbShiftPgUp)
+        ) {
+            document.up(getHeight() - 1);
+            alignTopLine(false);
+        } else if (keypress.equals(kbPgDn)
+            || keypress.equals(kbShiftPgDn)
+        ) {
+            document.down(getHeight() - 1);
+            alignTopLine(true);
+        } else if (keypress.equals(kbHome)
+            || keypress.equals(kbShiftHome)
+        ) {
+            if (document.home()) {
+                leftColumn = 0;
+                if (leftColumn < 0) {
+                    leftColumn = 0;
+                }
+                setCursorX(0);
+            }
+        } else if (keypress.equals(kbEnd)
+            || keypress.equals(kbShiftEnd)
+        ) {
+            if (document.end()) {
+                alignCursor();
+            }
+        } else if (keypress.equals(kbCtrlHome)
+            || keypress.equals(kbCtrlShiftHome)
+        ) {
+            document.setLineNumber(0);
+            document.home();
+            topLine = 0;
+            leftColumn = 0;
+            setCursorX(0);
+            setCursorY(0);
+        } else if (keypress.equals(kbCtrlEnd)
+            || keypress.equals(kbCtrlShiftEnd)
+        ) {
+            document.setLineNumber(document.getLineCount() - 1);
+            document.end();
+            alignTopLine(false);
+        } else if (keypress.equals(kbIns)) {
+            document.setOverwrite(!document.isOverwrite());
+        } else if (keypress.equals(kbDel)) {
+            if (inSelection) {
+                deleteSelection();
+                alignCursor();
+            } else {
+                saveUndo();
+                document.del();
+                alignCursor();
+            }
+        } else if (keypress.equals(kbBackspace)
+            || keypress.equals(kbBackspaceDel)
+        ) {
+            if (inSelection) {
+                deleteSelection();
+                alignTopLine(false);
+            } else {
+                saveUndo();
+                document.backspace();
+                alignTopLine(false);
+            }
+        } else if (keypress.equals(kbTab)) {
+            deleteSelection();
+            saveUndo();
+            document.tab();
+            alignCursor();
+        } else if (keypress.equals(kbShiftTab)) {
+            deleteSelection();
+            saveUndo();
+            document.backTab();
+            alignCursor();
+        } else if (keypress.equals(kbEnter)) {
+            deleteSelection();
+            saveUndo();
+            document.enter();
+            alignTopLine(true);
+        } else if (!keypress.getKey().isFnKey()
+            && !keypress.getKey().isAlt()
+            && !keypress.getKey().isCtrl()
+        ) {
+            // Plain old keystroke, process it
+            deleteSelection();
+            saveUndo();
+            document.addChar(keypress.getKey().getChar());
+            alignCursor();
+        } else {
+            // Pass other keys (tab etc.) on to TWidget
+            super.onKeypress(keypress);
+        }
+
+        if (inSelection) {
+            selectionColumn1 = document.getCursor();
+            selectionLine1 = document.getLineNumber();
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle window/screen resize
+     * events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        // Change my width/height, and pull the cursor in as needed.
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            setWidth(resize.getWidth());
+            setHeight(resize.getHeight());
+            // See if the cursor is now outside the window, and if so move
+            // things.
+            if (getCursorX() >= getWidth()) {
+                leftColumn += getCursorX() - (getWidth() - 1);
+                setCursorX(getWidth() - 1);
+            }
+            if (getCursorY() >= getHeight()) {
+                topLine += getCursorY() - (getHeight() - 1);
+                setCursorY(getHeight() - 1);
+            }
+        } else {
+            // Let superclass handle it
+            super.onResize(resize);
+        }
+    }
+
+    /**
+     * Handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (command.equals(cmCut)) {
+            // Copy text to clipboard, and then remove it.
+            copySelection();
+            deleteSelection();
+            return;
+        }
+
+        if (command.equals(cmCopy)) {
+            // Copy text to clipboard.
+            copySelection();
+            return;
+        }
+
+        if (command.equals(cmPaste)) {
+            // Delete selected text, then paste text from clipboard.
+            deleteSelection();
+
+            String text = getClipboard().pasteText();
+            if (text != null) {
+                for (int i = 0; i < text.length(); ) {
+                    int ch = text.codePointAt(i);
+                    switch (ch) {
+                    case '\n':
+                        onKeypress(new TKeypressEvent(kbEnter));
+                        break;
+                    case '\t':
+                        onKeypress(new TKeypressEvent(kbTab));
+                        break;
+                    default:
+                        if ((ch >= 0x20) && (ch != 0x7F)) {
+                            onKeypress(new TKeypressEvent(false, 0, ch,
+                                    false, false, false));
+                        }
+                        break;
+                    }
+
+                    i += Character.charCount(ch);
+                }
+            }
+            return;
+        }
+
+        if (command.equals(cmClear)) {
+            // Remove text.
+            deleteSelection();
+            return;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the text box.
+     */
+    @Override
+    public void draw() {
+        CellAttributes selectedColor = getTheme().getColor("teditor.selected");
+
+        boolean drawSelection = true;
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+        }
+        if ((startCol == endCol) && (startRow == endRow)) {
+            drawSelection = false;
+        }
+
+        for (int i = 0; i < getHeight(); i++) {
+            // Background line
+            getScreen().hLineXY(0, i, getWidth(), ' ', defaultColor);
+
+            // Now draw document's line
+            if (topLine + i < document.getLineCount()) {
+                Line line = document.getLine(topLine + i);
+                int x = 0;
+                for (Word word: line.getWords()) {
+                    // For now, we are cheating: draw outside the left region
+                    // if needed and let screen do the clipping.
+                    getScreen().putStringXY(x - leftColumn, i, word.getText(),
+                        word.getColor());
+                    x += word.getDisplayLength();
+                    if (x - leftColumn > getWidth()) {
+                        break;
+                    }
+                }
+
+                // Highlight selected region
+                if (inSelection && drawSelection) {
+                    if (startRow == endRow) {
+                        if (topLine + i == startRow) {
+                            for (x = startCol; x <= endCol; x++) {
+                                putAttrXY(x - leftColumn, i, selectedColor);
+                            }
+                        }
+                    } else {
+                        if (topLine + i == startRow) {
+                            for (x = startCol; x < line.getDisplayLength(); x++) {
+                                putAttrXY(x - leftColumn, i, selectedColor);
+                            }
+                        } else if (topLine + i == endRow) {
+                            for (x = 0; x <= endCol; x++) {
+                                putAttrXY(x - leftColumn, i, selectedColor);
+                            }
+                        } else if ((topLine + i >= startRow)
+                            && (topLine + i <= endRow)
+                        ) {
+                            for (x = 0; x < getWidth(); x++) {
+                                putAttrXY(x, i, selectedColor);
+                            }
+                        }
+                    }
+                }
+
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TEditorWidget ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the undo level.
+     *
+     * @param undoLevel the maximum number of undo operations
+     */
+    public void setUndoLevel(final int undoLevel) {
+        this.undoLevel = undoLevel;
+    }
+
+    /**
+     * Align visible area with document current line.
+     *
+     * @param topLineIsTop if true, make the top visible line the document
+     * current line if it was off-screen.  If false, make the bottom visible
+     * line the document current line.
+     */
+    private void alignTopLine(final boolean topLineIsTop) {
+        int line = document.getLineNumber();
+
+        if ((line < topLine) || (line > topLine + getHeight() - 1)) {
+            // Need to move topLine to bring document back into view.
+            if (topLineIsTop) {
+                topLine = line - (getHeight() - 1);
+                if (topLine < 0) {
+                    topLine = 0;
+                }
+                assert (topLine >= 0);
+            } else {
+                topLine = line;
+                assert (topLine >= 0);
+            }
+        }
+
+        /*
+        System.err.println("line " + line + " topLine " + topLine);
+        */
+
+        // Document is in view, let's set cursorY
+        assert (line >= topLine);
+        setCursorY(line - topLine);
+        alignCursor();
+    }
+
+    /**
+     * Align document current line with visible area.
+     *
+     * @param topLineIsTop if true, make the top visible line the document
+     * current line if it was off-screen.  If false, make the bottom visible
+     * line the document current line.
+     */
+    private void alignDocument(final boolean topLineIsTop) {
+        int line = document.getLineNumber();
+        int cursor = document.getCursor();
+
+        if ((line < topLine) || (line > topLine + getHeight() - 1)) {
+            // Need to move document to ensure it fits view.
+            if (topLineIsTop) {
+                document.setLineNumber(topLine);
+            } else {
+                document.setLineNumber(topLine + (getHeight() - 1));
+            }
+            if (cursor < document.getCurrentLine().getDisplayLength()) {
+                document.setCursor(cursor);
+            }
+        }
+
+        /*
+        System.err.println("getLineNumber() " + document.getLineNumber() +
+            " topLine " + topLine);
+        */
+
+        // Document is in view, let's set cursorY
+        setCursorY(document.getLineNumber() - topLine);
+        alignCursor();
+    }
+
+    /**
+     * Align visible cursor with document cursor.
+     */
+    private void alignCursor() {
+        int width = getWidth();
+
+        int desiredX = document.getCursor() - leftColumn;
+        if (desiredX < 0) {
+            // We need to push the screen to the left.
+            leftColumn = document.getCursor();
+        } else if (desiredX > width - 1) {
+            // We need to push the screen to the right.
+            leftColumn = document.getCursor() - (width - 1);
+        }
+
+        /*
+        System.err.println("document cursor " + document.getCursor() +
+            " leftColumn " + leftColumn);
+        */
+
+
+        setCursorX(document.getCursor() - leftColumn);
+    }
+
+    /**
+     * Get the number of lines in the underlying Document.
+     *
+     * @return the number of lines
+     */
+    public int getLineCount() {
+        return document.getLineCount();
+    }
+
+    /**
+     * Get the current visible top row number.  1-based.
+     *
+     * @return the visible top row number.  Row 1 is the first row.
+     */
+    public int getVisibleRowNumber() {
+        return topLine + 1;
+    }
+
+    /**
+     * Set the current visible row number.  1-based.
+     *
+     * @param row the new visible row number.  Row 1 is the first row.
+     */
+    public void setVisibleRowNumber(final int row) {
+        assert (row > 0);
+        if ((row > 0) && (row < document.getLineCount())) {
+            topLine = row - 1;
+            alignDocument(true);
+        }
+    }
+
+    /**
+     * Get the current editing row number.  1-based.
+     *
+     * @return the editing row number.  Row 1 is the first row.
+     */
+    public int getEditingRowNumber() {
+        return document.getLineNumber() + 1;
+    }
+
+    /**
+     * Set the current editing row number.  1-based.
+     *
+     * @param row the new editing row number.  Row 1 is the first row.
+     */
+    public void setEditingRowNumber(final int row) {
+        assert (row > 0);
+        if ((row > 0) && (row < document.getLineCount())) {
+            document.setLineNumber(row - 1);
+            alignTopLine(true);
+        }
+    }
+
+    /**
+     * Set the current visible column number.  1-based.
+     *
+     * @return the visible column number.  Column 1 is the first column.
+     */
+    public int getVisibleColumnNumber() {
+        return leftColumn + 1;
+    }
+
+    /**
+     * Set the current visible column number.  1-based.
+     *
+     * @param column the new visible column number.  Column 1 is the first
+     * column.
+     */
+    public void setVisibleColumnNumber(final int column) {
+        assert (column > 0);
+        if ((column > 0) && (column < document.getLineLengthMax())) {
+            leftColumn = column - 1;
+            alignDocument(true);
+        }
+    }
+
+    /**
+     * Get the current editing column number.  1-based.
+     *
+     * @return the editing column number.  Column 1 is the first column.
+     */
+    public int getEditingColumnNumber() {
+        return document.getCursor() + 1;
+    }
+
+    /**
+     * Set the current editing column number.  1-based.
+     *
+     * @param column the new editing column number.  Column 1 is the first
+     * column.
+     */
+    public void setEditingColumnNumber(final int column) {
+        if ((column > 0) && (column < document.getLineLength())) {
+            document.setCursor(column - 1);
+            alignCursor();
+        }
+    }
+
+    /**
+     * Get the maximum possible row number.  1-based.
+     *
+     * @return the maximum row number.  Row 1 is the first row.
+     */
+    public int getMaximumRowNumber() {
+        return document.getLineCount() + 1;
+    }
+
+    /**
+     * Get the maximum possible column number.  1-based.
+     *
+     * @return the maximum column number.  Column 1 is the first column.
+     */
+    public int getMaximumColumnNumber() {
+        return document.getLineLengthMax() + 1;
+    }
+
+    /**
+     * Get the current editing row plain text.  1-based.
+     *
+     * @param row the editing row number.  Row 1 is the first row.
+     * @return the plain text of the row
+     */
+    public String getEditingRawLine(final int row) {
+        Line line  = document.getLine(row - 1);
+        return line.getRawString();
+    }
+
+    /**
+     * Get the dirty value.
+     *
+     * @return true if the buffer is dirty
+     */
+    public boolean isDirty() {
+        return document.isDirty();
+    }
+
+    /**
+     * Unset the dirty flag.
+     */
+    public void setNotDirty() {
+        document.setNotDirty();
+    }
+
+    /**
+     * Get the overwrite value.
+     *
+     * @return true if new text will overwrite old text
+     */
+    public boolean isOverwrite() {
+        return document.isOverwrite();
+    }
+
+    /**
+     * Save contents to file.
+     *
+     * @param filename file to save to
+     * @throws IOException if a java.io operation throws
+     */
+    public void saveToFilename(final String filename) throws IOException {
+        document.saveToFilename(filename);
+    }
+
+    /**
+     * Delete text within the selection bounds.
+     */
+    private void deleteSelection() {
+        if (!inSelection) {
+            return;
+        }
+
+        saveUndo();
+
+        inSelection = false;
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        /*
+        System.err.println("INITIAL: " + startRow + " " + startCol + " " +
+            endRow + " " + endCol + " " +
+            document.getLineNumber() + " " + document.getCursor());
+         */
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+
+            if (endRow >= document.getLineCount()) {
+                // The selection started beyond EOF, trim it to EOF.
+                endRow = document.getLineCount() - 1;
+                endCol = document.getLine(endRow).getDisplayLength();
+            } else if (endRow == document.getLineCount() - 1) {
+                // The selection started beyond EOF, trim it to EOF.
+                if (endCol >= document.getLine(endRow).getDisplayLength()) {
+                    endCol = document.getLine(endRow).getDisplayLength() - 1;
+                }
+            }
+        }
+        /*
+        System.err.println("FLIP: " + startRow + " " + startCol + " " +
+            endRow + " " + endCol + " " +
+            document.getLineNumber() + " " + document.getCursor());
+        System.err.println(" --END: " + endRow + " " + document.getLineCount() +
+            " " + document.getLine(endRow).getDisplayLength());
+         */
+
+        assert (endRow < document.getLineCount());
+        if (endCol >= document.getLine(endRow).getDisplayLength()) {
+            endCol = document.getLine(endRow).getDisplayLength() - 1;
+        }
+        if (endCol < 0) {
+            endCol = 0;
+        }
+        if (startCol >= document.getLine(startRow).getDisplayLength()) {
+            startCol = document.getLine(startRow).getDisplayLength() - 1;
+        }
+        if (startCol < 0) {
+            startCol = 0;
+        }
+
+        // Place the cursor on the selection end, and "press backspace" until
+        // the cursor matches the selection start.
+        /*
+        System.err.println("BEFORE: " + startRow + " " + startCol + " " +
+            endRow + " " + endCol + " " +
+            document.getLineNumber() + " " + document.getCursor());
+         */
+        document.setLineNumber(endRow);
+        document.setCursor(endCol + 1);
+        while (!((document.getLineNumber() == startRow)
+                && (document.getCursor() == startCol))
+        ) {
+            /*
+            System.err.println("DURING: " + startRow + " " + startCol + " " +
+                endRow + " " + endCol + " " +
+                document.getLineNumber() + " " + document.getCursor());
+             */
+
+            document.backspace();
+        }
+        alignTopLine(true);
+    }
+
+    /**
+     * Copy text within the selection bounds to clipboard.
+     */
+    private void copySelection() {
+        if (!inSelection) {
+            return;
+        }
+        getClipboard().copyText(getSelection());
+    }
+
+    /**
+     * Set the selection.
+     *
+     * @param startRow the starting row number.  0-based: row 0 is the first
+     * row.
+     * @param startColumn the starting column number.  0-based: column 0 is
+     * the first column.
+     * @param endRow the ending row number.  0-based: row 0 is the first row.
+     * @param endColumn the ending column number.  0-based: column 0 is the
+     * first column.
+     */
+    public void setSelection(final int startRow, final int startColumn,
+        final int endRow, final int endColumn) {
+
+        inSelection = true;
+        selectionLine0 = startRow;
+        selectionColumn0 = startColumn;
+        selectionLine1 = endRow;
+        selectionColumn1 = endColumn;
+    }
+
+    /**
+     * Copy text within the selection bounds to a string.
+     *
+     * @return the selection as a string, or null if there is no selection
+     */
+    public String getSelection() {
+        if (!inSelection) {
+            return null;
+        }
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+        }
+
+        StringBuilder sb = new StringBuilder();
+
+        if (endRow > startRow) {
+            // First line
+            String line = document.getLine(startRow).getRawString();
+            int x = 0;
+            for (int i = 0; i < line.length(); ) {
+                int ch = line.codePointAt(i);
+
+                if (x >= startCol) {
+                    sb.append(Character.toChars(ch));
+                }
+                x += StringUtils.width(ch);
+                i += Character.charCount(ch);
+            }
+            sb.append("\n");
+
+            // Middle lines
+            for (int y = startRow + 1; y < endRow; y++) {
+                sb.append(document.getLine(y).getRawString());
+                sb.append("\n");
+            }
+
+            // Final line
+            line = document.getLine(endRow).getRawString();
+            x = 0;
+            for (int i = 0; i < line.length(); ) {
+                int ch = line.codePointAt(i);
+
+                if (x > endCol) {
+                    break;
+                }
+
+                sb.append(Character.toChars(ch));
+                x += StringUtils.width(ch);
+                i += Character.charCount(ch);
+            }
+        } else {
+            assert (startRow == endRow);
+
+            // Only one line
+            String line = document.getLine(startRow).getRawString();
+            int x = 0;
+            for (int i = 0; i < line.length(); ) {
+                int ch = line.codePointAt(i);
+
+                if ((x >= startCol) && (x <= endCol)) {
+                    sb.append(Character.toChars(ch));
+                }
+
+                x += StringUtils.width(ch);
+                i += Character.charCount(ch);
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Get the selection starting row number.
+     *
+     * @return the starting row number, or -1 if there is no selection.
+     * 0-based: row 0 is the first row.
+     */
+    public int getSelectionStartRow() {
+        if (!inSelection) {
+            return -1;
+        }
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+        }
+        return startRow;
+    }
+
+    /**
+     * Get the selection starting column number.
+     *
+     * @return the starting column number, or -1 if there is no selection.
+     * 0-based: column 0 is the first column.
+     */
+    public int getSelectionStartColumn() {
+        if (!inSelection) {
+            return -1;
+        }
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+        }
+        return startCol;
+    }
+
+    /**
+     * Get the selection ending row number.
+     *
+     * @return the ending row number, or -1 if there is no selection.
+     * 0-based: row 0 is the first row.
+     */
+    public int getSelectionEndRow() {
+        if (!inSelection) {
+            return -1;
+        }
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+        }
+        return endRow;
+    }
+
+    /**
+     * Get the selection ending column number.
+     *
+     * @return the ending column number, or -1 if there is no selection.
+     * 0-based: column 0 is the first column.
+     */
+    public int getSelectionEndColumn() {
+        if (!inSelection) {
+            return -1;
+        }
+
+        int startCol = selectionColumn0;
+        int startRow = selectionLine0;
+        int endCol = selectionColumn1;
+        int endRow = selectionLine1;
+
+        if (((selectionColumn1 < selectionColumn0)
+                && (selectionLine1 == selectionLine0))
+            || (selectionLine1 < selectionLine0)
+        ) {
+            // The user selected from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startCol = selectionColumn1;
+            startRow = selectionLine1;
+            endCol = selectionColumn0;
+            endRow = selectionLine0;
+        }
+        return endCol;
+    }
+
+    /**
+     * Unset the selection.
+     */
+    public void unsetSelection() {
+        inSelection = false;
+    }
+
+    /**
+     * Replace whatever is being selected with new text.  If not in
+     * selection, nothing is replaced.
+     *
+     * @param text the new replacement text
+     */
+    public void replaceSelection(final String text) {
+        if (!inSelection) {
+            return;
+        }
+
+        // Delete selected text, then paste text from clipboard.
+        deleteSelection();
+
+        for (int i = 0; i < text.length(); ) {
+            int ch = text.codePointAt(i);
+            switch (ch) {
+            case '\n':
+                onKeypress(new TKeypressEvent(kbEnter));
+                break;
+            case '\t':
+                onKeypress(new TKeypressEvent(kbTab));
+                break;
+            default:
+                if ((ch >= 0x20) && (ch != 0x7F)) {
+                    onKeypress(new TKeypressEvent(false, 0, ch,
+                            false, false, false));
+                }
+                break;
+            }
+            i += Character.charCount(ch);
+        }
+    }
+
+    /**
+     * Check if selection is available.
+     *
+     * @return true if a selection has been made
+     */
+    public boolean hasSelection() {
+        return inSelection;
+    }
+
+    /**
+     * Get the entire contents of the editor as one string.
+     *
+     * @return the editor contents
+     */
+    public String getText() {
+        return document.getText();
+    }
+
+    /**
+     * Set the entire contents of the editor from one string.
+     *
+     * @param text the new contents
+     */
+    public void setText(final String text) {
+        document = new Document(text, defaultColor);
+        unsetSelection();
+        topLine = 0;
+        leftColumn = 0;
+    }
+
+    // ------------------------------------------------------------------------
+    // EditMenuUser -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if the cut menu item should be enabled.
+     *
+     * @return true if the cut menu item should be enabled
+     */
+    public boolean isEditMenuCut() {
+        return true;
+    }
+
+    /**
+     * Check if the copy menu item should be enabled.
+     *
+     * @return true if the copy menu item should be enabled
+     */
+    public boolean isEditMenuCopy() {
+        return true;
+    }
+
+    /**
+     * Check if the paste menu item should be enabled.
+     *
+     * @return true if the paste menu item should be enabled
+     */
+    public boolean isEditMenuPaste() {
+        return true;
+    }
+
+    /**
+     * Check if the clear menu item should be enabled.
+     *
+     * @return true if the clear menu item should be enabled
+     */
+    public boolean isEditMenuClear() {
+        return true;
+    }
+
+    /**
+     * Save undo state.
+     */
+    private void saveUndo() {
+        SavedState state = new SavedState();
+        state.document = document.dup();
+        state.topLine = topLine;
+        state.leftColumn = leftColumn;
+        if (undoLevel > 0) {
+            while (undoList.size() > undoLevel) {
+                undoList.remove(0);
+            }
+        }
+        undoList.add(state);
+        undoListI = undoList.size() - 1;
+    }
+
+    /**
+     * Undo an edit.
+     */
+    public void undo() {
+        inSelection = false;
+        if ((undoListI >= 0) && (undoListI < undoList.size())) {
+            SavedState state = undoList.get(undoListI);
+            document = state.document.dup();
+            topLine = state.topLine;
+            leftColumn = state.leftColumn;
+            undoListI--;
+            setCursorY(document.getLineNumber() - topLine);
+            alignCursor();
+        }
+    }
+
+    /**
+     * Redo an edit.
+     */
+    public void redo() {
+        inSelection = false;
+        if ((undoListI >= 0) && (undoListI < undoList.size())) {
+            SavedState state = undoList.get(undoListI);
+            document = state.document.dup();
+            topLine = state.topLine;
+            leftColumn = state.leftColumn;
+            undoListI++;
+            setCursorY(document.getLineNumber() - topLine);
+            alignCursor();
+        }
+    }
+
+    /**
+     * Trim trailing whitespace from lines and trailing empty
+     * lines from the document.
+     */
+    public void cleanWhitespace() {
+        document.cleanWhitespace();
+        setCursorY(document.getLineNumber() - topLine);
+        alignCursor();
+    }
+
+    /**
+     * Set keyword highlighting.
+     *
+     * @param enabled if true, enable keyword highlighting
+     */
+    public void setHighlighting(final boolean enabled) {
+        document.setHighlighting(enabled);
+    }
+
+}
diff --git a/src/jexer/TEditorWindow.java b/src/jexer/TEditorWindow.java
new file mode 100644 (file)
index 0000000..a28376b
--- /dev/null
@@ -0,0 +1,495 @@
+/*
+ * 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.io.File;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ResourceBundle;
+import java.util.Scanner;
+
+import jexer.TApplication;
+import jexer.TEditorWidget;
+import jexer.THScroller;
+import jexer.TScrollableWindow;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.menu.TMenu;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TEditorWindow is a basic text file editor.
+ */
+public class TEditorWindow extends TScrollableWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TEditorWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto my TEditor so I can resize it with the window.
+     */
+    private TEditorWidget editField;
+
+    /**
+     * The fully-qualified name of the file being edited.
+     */
+    private String filename = "";
+
+    /**
+     * If true, hide the mouse after typing a keystroke.
+     */
+    private boolean hideMouseWhenTyping = true;
+
+    /**
+     * If true, the mouse should not be displayed because a keystroke was
+     * typed.
+     */
+    private boolean typingHidMouse = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor sets window title.
+     *
+     * @param parent the main application
+     * @param title the window title
+     */
+    public TEditorWindow(final TApplication parent, final String title) {
+
+        super(parent, title, 0, 0, parent.getScreen().getWidth(),
+            parent.getDesktopBottom() - parent.getDesktopTop(), RESIZABLE);
+
+        editField = addEditor("", 0, 0, getWidth() - 2, getHeight() - 2);
+        setupAfterEditor();
+    }
+
+    /**
+     * Public constructor sets window title and contents.
+     *
+     * @param parent the main application
+     * @param title the window title, usually a filename
+     * @param contents the data for the editing window, usually the file data
+     */
+    public TEditorWindow(final TApplication parent, final String title,
+        final String contents) {
+
+        super(parent, title, 0, 0, parent.getScreen().getWidth(),
+            parent.getDesktopBottom() - parent.getDesktopTop(), RESIZABLE);
+
+        filename = title;
+        editField = addEditor(contents, 0, 0, getWidth() - 2, getHeight() - 2);
+        setupAfterEditor();
+    }
+
+    /**
+     * Public constructor opens a file.
+     *
+     * @param parent the main application
+     * @param file the file to open
+     * @throws IOException if a java.io operation throws
+     */
+    public TEditorWindow(final TApplication parent,
+        final File file) throws IOException {
+
+        super(parent, file.getName(), 0, 0, parent.getScreen().getWidth(),
+            parent.getDesktopBottom() - parent.getDesktopTop(), RESIZABLE);
+
+        filename = file.getName();
+        String contents = readFileData(file);
+        editField = addEditor(contents, 0, 0, getWidth() - 2, getHeight() - 2);
+        setupAfterEditor();
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     */
+    public TEditorWindow(final TApplication parent) {
+        this(parent, i18n.getString("newTextDocument"));
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Called by application.switchWindow() when this window gets the
+     * focus, and also by application.addWindow().
+     */
+    public void onFocus() {
+        super.onFocus();
+        getApplication().enableMenuItem(TMenu.MID_UNDO);
+        getApplication().enableMenuItem(TMenu.MID_REDO);
+    }
+
+    /**
+     * Called by application.switchWindow() when another window gets the
+     * focus.
+     */
+    public void onUnfocus() {
+        super.onUnfocus();
+        getApplication().disableMenuItem(TMenu.MID_UNDO);
+        getApplication().disableMenuItem(TMenu.MID_REDO);
+    }
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseDown(mouse);
+
+        if (hideMouseWhenTyping) {
+            typingHidMouse = false;
+        }
+
+        if (mouseOnEditor(mouse)) {
+            // The editor might have changed, update the scollbars.
+            setBottomValue(editField.getMaximumRowNumber());
+            setVerticalValue(editField.getVisibleRowNumber());
+            setRightValue(editField.getMaximumColumnNumber());
+            setHorizontalValue(editField.getEditingColumnNumber());
+        } else {
+            if (mouse.isMouseWheelUp() || mouse.isMouseWheelDown()) {
+                // Vertical scrollbar actions
+                editField.setVisibleRowNumber(getVerticalValue());
+            }
+        }
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseUp(mouse);
+
+        if (hideMouseWhenTyping) {
+            typingHidMouse = false;
+        }
+
+        if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+            // Clicked on vertical scrollbar
+            editField.setVisibleRowNumber(getVerticalValue());
+        }
+        if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+            // Clicked on horizontal scrollbar
+            editField.setVisibleColumnNumber(getHorizontalValue());
+            setHorizontalValue(editField.getVisibleColumnNumber());
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseMotion(mouse);
+
+        if (hideMouseWhenTyping) {
+            typingHidMouse = false;
+        }
+
+        if (mouseOnEditor(mouse) && mouse.isMouse1()) {
+            // The editor might have changed, update the scollbars.
+            setBottomValue(editField.getMaximumRowNumber());
+            setVerticalValue(editField.getVisibleRowNumber());
+            setRightValue(editField.getMaximumColumnNumber());
+            setHorizontalValue(editField.getEditingColumnNumber());
+        } else {
+            if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+                // Clicked/dragged on vertical scrollbar
+                editField.setVisibleRowNumber(getVerticalValue());
+            }
+            if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+                // Clicked/dragged on horizontal scrollbar
+                editField.setVisibleColumnNumber(getHorizontalValue());
+                setHorizontalValue(editField.getVisibleColumnNumber());
+            }
+        }
+
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (hideMouseWhenTyping) {
+            typingHidMouse = true;
+        }
+
+        // Use TWidget's code to pass the event to the children.
+        super.onKeypress(keypress);
+
+        // The editor might have changed, update the scollbars.
+        setBottomValue(editField.getMaximumRowNumber());
+        setVerticalValue(editField.getVisibleRowNumber());
+        setRightValue(editField.getMaximumColumnNumber());
+        setHorizontalValue(editField.getEditingColumnNumber());
+    }
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the text field
+            TResizeEvent editSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                event.getWidth() - 2, event.getHeight() - 2);
+            editField.onResize(editSize);
+
+            // Have TScrollableWindow handle the scrollbars
+            super.onResize(event);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(event);
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (command.equals(cmOpen)) {
+            try {
+                String filename = fileOpenBox(".");
+                if (filename != null) {
+                    try {
+                        String contents = readFileData(filename);
+                        new TEditorWindow(getApplication(), filename, contents);
+                    } catch (IOException e) {
+                        messageBox(i18n.getString("errorDialogTitle"),
+                            MessageFormat.format(i18n.
+                                getString("errorReadingFile"), e.getMessage()));
+                    }
+                }
+            } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorOpeningFileDialog"), e.getMessage()));
+            }
+            return;
+        }
+
+        if (command.equals(cmSave)) {
+            if (filename.length() > 0) {
+                try {
+                    editField.saveToFilename(filename);
+                } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorSavingFile"), e.getMessage()));
+                }
+            }
+            return;
+        }
+
+        // Didn't handle it, let children get it instead
+        super.onCommand(command);
+    }
+
+    /**
+     * Handle posted menu events.
+     *
+     * @param menu menu event
+     */
+    @Override
+    public void onMenu(final TMenuEvent menu) {
+        switch (menu.getId()) {
+        case TMenu.MID_UNDO:
+            editField.undo();
+            break;
+        case TMenu.MID_REDO:
+            editField.redo();
+            break;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the window.
+     */
+    @Override
+    public void draw() {
+        // Draw as normal.
+        super.draw();
+
+        // Add the row:col on the bottom row
+        CellAttributes borderColor = getBorder();
+        String location = String.format(" %d:%d ",
+            editField.getEditingRowNumber(),
+            editField.getEditingColumnNumber());
+        int colon = location.indexOf(':');
+        putStringXY(10 - colon, getHeight() - 1, location, borderColor);
+
+        if (editField.isDirty()) {
+            putCharXY(2, getHeight() - 1, GraphicsChars.OCTOSTAR, borderColor);
+        }
+    }
+
+    /**
+     * Returns true if this window does not want the application-wide mouse
+     * cursor drawn over it.
+     *
+     * @return true if this window does not want the application-wide mouse
+     * cursor drawn over it
+     */
+    @Override
+    public boolean hasHiddenMouse() {
+        return (super.hasHiddenMouse() || typingHidMouse);
+    }
+
+    // ------------------------------------------------------------------------
+    // TEditorWindow ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Setup other fields after the editor is created.
+     */
+    private void setupAfterEditor() {
+        hScroller = new THScroller(this, 17, getHeight() - 2, getWidth() - 20);
+        vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+        setMinimumWindowWidth(25);
+        setMinimumWindowHeight(10);
+        setTopValue(1);
+        setBottomValue(editField.getMaximumRowNumber());
+        setLeftValue(1);
+        setRightValue(editField.getMaximumColumnNumber());
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmSave,
+            i18n.getString("statusBarSave"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmMenu,
+            i18n.getString("statusBarMenu"));
+
+        // Hide mouse when typing option
+        if (System.getProperty("jexer.TEditor.hideMouseWhenTyping",
+                "true").equals("false")) {
+
+            hideMouseWhenTyping = false;
+        }
+    }
+
+    /**
+     * Read file data into a string.
+     *
+     * @param file the file to open
+     * @return the file contents
+     * @throws IOException if a java.io operation throws
+     */
+    private String readFileData(final File file) throws IOException {
+        StringBuilder fileContents = new StringBuilder();
+        Scanner scanner = new Scanner(file);
+        String EOL = System.getProperty("line.separator");
+
+        try {
+            while (scanner.hasNextLine()) {
+                fileContents.append(scanner.nextLine() + EOL);
+            }
+            return fileContents.toString();
+        } finally {
+            scanner.close();
+        }
+    }
+
+    /**
+     * Read file data into a string.
+     *
+     * @param filename the file to open
+     * @return the file contents
+     * @throws IOException if a java.io operation throws
+     */
+    private String readFileData(final String filename) throws IOException {
+        return readFileData(new File(filename));
+    }
+
+    /**
+     * Check if a mouse press/release/motion event coordinate is over the
+     * editor.
+     *
+     * @param mouse a mouse-based event
+     * @return whether or not the mouse is on the editor
+     */
+    private boolean mouseOnEditor(final TMouseEvent mouse) {
+        if ((mouse.getAbsoluteX() >= getAbsoluteX() + 1)
+            && (mouse.getAbsoluteX() <  getAbsoluteX() + getWidth() - 1)
+            && (mouse.getAbsoluteY() >= getAbsoluteY() + 1)
+            && (mouse.getAbsoluteY() <  getAbsoluteY() + getHeight() - 1)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+}
diff --git a/src/jexer/TEditorWindow.properties b/src/jexer/TEditorWindow.properties
new file mode 100644 (file)
index 0000000..d18b078
--- /dev/null
@@ -0,0 +1,10 @@
+statusBar=Editor
+statusBarHelp=Help
+statusBarSave=Save
+statusBarOpen=Open
+statusBarMenu=Menu
+newTextDocument=New Text Document
+errorDialogTitle=Error
+errorReadingFile=Error reading file: {0}
+errorOpeningFileDialog=Error opening file dialog: {0}
+errorSavingFile=Error saving file: {0}
diff --git a/src/jexer/TExceptionDialog.java b/src/jexer/TExceptionDialog.java
new file mode 100644 (file)
index 0000000..f526a64
--- /dev/null
@@ -0,0 +1,208 @@
+/*
+ * 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.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.ResourceBundle;
+
+import jexer.bits.CellAttributes;
+
+/**
+ * TExceptionDialog displays an exception and its stack trace to the user,
+ * and provides a means to save a troubleshooting report for support.
+ */
+public class TExceptionDialog extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TExceptionDialog.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The exception.  We will actually make it Throwable, for the unlikely
+     * event we catch an Error rather than an Exception.
+     */
+    private Throwable exception;
+
+    /**
+     * The exception's stack trace.
+     */
+    private TList stackTrace;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param application TApplication that manages this window
+     * @param exception the exception to display
+     */
+    public TExceptionDialog(final TApplication application,
+        final Throwable exception) {
+
+        super(application, i18n.getString("windowTitle"),
+            1, 1, 78, 22, CENTERED | MODAL);
+
+        this.exception = exception;
+
+        addLabel(i18n.getString("captionLine1"), 1, 1,
+            "twindow.background.modal");
+        addLabel(i18n.getString("captionLine2"), 1, 2,
+            "twindow.background.modal");
+        addLabel(i18n.getString("captionLine3"), 1, 3,
+            "twindow.background.modal");
+        addLabel(i18n.getString("captionLine4"), 1, 4,
+            "twindow.background.modal");
+
+        addLabel(MessageFormat.format(i18n.getString("exceptionString"),
+                exception.getClass().getName(), exception.getMessage()),
+            2, 6, "ttext", false);
+
+        ArrayList<String> stackTraceStrings = new ArrayList<String>();
+        stackTraceStrings.add(exception.getMessage());
+        StackTraceElement [] stack = exception.getStackTrace();
+        for (int i = 0; i < stack.length; i++) {
+            stackTraceStrings.add(stack[i].toString());
+        }
+        stackTrace = addList(stackTraceStrings, 2, 7, getWidth() - 6, 10);
+
+        // Buttons
+        addButton(i18n.getString("saveButton"), 21, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    saveToFile();
+                }
+            });
+
+        TButton closeButton = addButton(i18n.getString("closeButton"),
+            37, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    // Don't do anything, just close the window.
+                    TExceptionDialog.this.close();
+                }
+            });
+
+        // Save this for last: make the close button default action.
+        activate(closeButton);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the exception message background.
+     */
+    @Override
+    public void draw() {
+        // Draw window and border.
+        super.draw();
+
+        CellAttributes boxColor = getTheme().getColor("ttext");
+        hLineXY(3, 7, getWidth() - 6, ' ', boxColor);
+    }
+
+    // ------------------------------------------------------------------------
+    // TExceptionDialog -------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Save a troubleshooting report to file.  Note that we do NOT translate
+     * the strings within the error report.
+     */
+    private void saveToFile() {
+        // Prompt for filename.
+        PrintWriter writer = null;
+        try {
+            String filename = fileSaveBox(".");
+            if (filename == null) {
+                // User cancelled, bail out.
+                return;
+            }
+            writer = new PrintWriter(new FileWriter(filename));
+            writer.write("Date: " + new Date(System.currentTimeMillis())
+                + "\n");
+
+            // System properties
+            writer.write("System properties:\n");
+            writer.write("-----------------------------------\n");
+            System.getProperties().store(writer, null);
+            writer.write("-----------------------------------\n");
+            writer.write("\n");
+
+            // The exception we caught
+            writer.write("Caught exception:\n");
+            writer.write("-----------------------------------\n");
+            exception.printStackTrace(writer);
+            writer.write("-----------------------------------\n");
+            writer.write("\n");
+            // The exception's cause, if it was set
+            if (exception.getCause() != null) {
+                writer.write("Caught exception's cause:\n");
+                writer.write("-----------------------------------\n");
+                exception.getCause().printStackTrace(writer);
+                writer.write("-----------------------------------\n");
+            }
+            writer.write("\n");
+
+            // The UI stack trace
+            writer.write("UI stack trace:\n");
+            writer.write("-----------------------------------\n");
+            (new Throwable("UI Thread")).printStackTrace(writer);
+            writer.write("-----------------------------------\n");
+            writer.write("\n");
+            writer.close();
+        } catch (IOException e) {
+            messageBox(i18n.getString("errorDialogTitle"),
+                MessageFormat.format(i18n.
+                    getString("errorSavingFile"), e.getMessage()));
+        } finally {
+            if (writer != null) {
+                writer.close();
+                writer = null;
+            }
+        }
+    }
+}
diff --git a/src/jexer/TExceptionDialog.properties b/src/jexer/TExceptionDialog.properties
new file mode 100644 (file)
index 0000000..9e5857a
--- /dev/null
@@ -0,0 +1,15 @@
+windowTitle=Java Exception Caught
+statusBar=Exception
+
+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}
+
+saveButton=&Save Report
+closeButton=\ \ \ &Close\ \ \ 
+
+errorDialogTitle=Error
+errorSavingFile=Error saving file: {0}
diff --git a/src/jexer/TField.java b/src/jexer/TField.java
new file mode 100644 (file)
index 0000000..90dd4e4
--- /dev/null
@@ -0,0 +1,755 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TField implements an editable text field.
+ */
+public class TField extends TWidget implements EditMenuUser {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Background character for unfilled-in text.
+     */
+    protected int backgroundChar = GraphicsChars.HATCH;
+
+    /**
+     * Field text.
+     */
+    protected String text = "";
+
+    /**
+     * If true, only allow enough characters that will fit in the width.  If
+     * false, allow the field to scroll to the right.
+     */
+    protected boolean fixed = false;
+
+    /**
+     * Current editing position within text.
+     */
+    protected int position = 0;
+
+    /**
+     * Current editing position screen column number.
+     */
+    protected int screenPosition = 0;
+
+    /**
+     * Beginning of visible portion.
+     */
+    protected int windowStart = 0;
+
+    /**
+     * If true, new characters are inserted at position.
+     */
+    protected boolean insertMode = true;
+
+    /**
+     * Remember mouse state.
+     */
+    protected TMouseEvent mouse;
+
+    /**
+     * The action to perform when the user presses enter.
+     */
+    protected TAction enterAction;
+
+    /**
+     * The action to perform when the text is updated.
+     */
+    protected TAction updateAction;
+
+    /**
+     * The color to use when this field is active.
+     */
+    private String activeColorKey = "tfield.active";
+
+    /**
+     * The color to use when this field is not active.
+     */
+    private String inactiveColorKey = "tfield.inactive";
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     */
+    public TField(final TWidget parent, final int x, final int y,
+        final int width, final boolean fixed) {
+
+        this(parent, x, y, width, fixed, "", null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     */
+    public TField(final TWidget parent, final int x, final int y,
+        final int width, final boolean fixed, final String text) {
+
+        this(parent, x, y, width, fixed, text, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     * @param enterAction function to call when enter key is pressed
+     * @param updateAction function to call when the text is updated
+     */
+    public TField(final TWidget parent, final int x, final int y,
+        final int width, final boolean fixed, final String text,
+        final TAction enterAction, final TAction updateAction) {
+
+        // Set parent and window
+        super(parent, x, y, width, 1);
+
+        setCursorVisible(true);
+        this.fixed = fixed;
+        this.text = text;
+        this.enterAction = enterAction;
+        this.updateAction = updateAction;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the field.
+     *
+     * @return if true the mouse is currently on the field
+     */
+    protected boolean mouseOnField() {
+        int rightEdge = getWidth() - 1;
+        if ((mouse != null)
+            && (mouse.getY() == 0)
+            && (mouse.getX() >= 0)
+            && (mouse.getX() <= rightEdge)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if ((mouseOnField()) && (mouse.isMouse1())) {
+            // Move cursor
+            int deltaX = mouse.getX() - getCursorX();
+            screenPosition += deltaX;
+            if (screenPosition > StringUtils.width(text)) {
+                screenPosition = StringUtils.width(text);
+            }
+            position = screenToTextPosition(screenPosition);
+            updateCursor();
+            return;
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+
+        if (keypress.equals(kbLeft)) {
+            if (position > 0) {
+                screenPosition -= StringUtils.width(text.codePointBefore(position));
+                position -= Character.charCount(text.codePointBefore(position));
+            }
+            if (fixed == false) {
+                if ((screenPosition == windowStart) && (windowStart > 0)) {
+                    windowStart -= StringUtils.width(text.codePointAt(
+                        screenToTextPosition(windowStart)));
+                }
+            }
+            normalizeWindowStart();
+            return;
+        }
+
+        if (keypress.equals(kbRight)) {
+            if (position < text.length()) {
+                int lastPosition = position;
+                screenPosition += StringUtils.width(text.codePointAt(position));
+                position += Character.charCount(text.codePointAt(position));
+                if (fixed == true) {
+                    if (screenPosition == getWidth()) {
+                        screenPosition--;
+                        position -= Character.charCount(text.codePointAt(lastPosition));
+                    }
+                } else {
+                    while ((screenPosition - windowStart +
+                            StringUtils.width(text.codePointAt(text.length() - 1)))
+                        > getWidth()
+                    ) {
+                        windowStart += StringUtils.width(text.codePointAt(
+                            screenToTextPosition(windowStart)));
+                    }
+                }
+            }
+            assert (position <= text.length());
+            return;
+        }
+
+        if (keypress.equals(kbEnter)) {
+            dispatch(true);
+            return;
+        }
+
+        if (keypress.equals(kbIns)) {
+            insertMode = !insertMode;
+            return;
+        }
+        if (keypress.equals(kbHome)) {
+            home();
+            return;
+        }
+
+        if (keypress.equals(kbEnd)) {
+            end();
+            return;
+        }
+
+        if (keypress.equals(kbDel)) {
+            if ((text.length() > 0) && (position < text.length())) {
+                text = text.substring(0, position)
+                        + text.substring(position + 1);
+                screenPosition = StringUtils.width(text.substring(0, position));
+            }
+            dispatch(false);
+            return;
+        }
+
+        if (keypress.equals(kbBackspace) || keypress.equals(kbBackspaceDel)) {
+            if (position > 0) {
+                position -= Character.charCount(text.codePointBefore(position));
+                text = text.substring(0, position)
+                        + text.substring(position + 1);
+                screenPosition = StringUtils.width(text.substring(0, position));
+            }
+            if (fixed == false) {
+                if ((screenPosition >= windowStart)
+                    && (windowStart > 0)
+                ) {
+                    windowStart -= StringUtils.width(text.codePointAt(
+                        screenToTextPosition(windowStart)));
+                }
+            }
+            dispatch(false);
+            normalizeWindowStart();
+            return;
+        }
+
+        if (!keypress.getKey().isFnKey()
+            && !keypress.getKey().isAlt()
+            && !keypress.getKey().isCtrl()
+        ) {
+            // Plain old keystroke, process it
+            if ((position == text.length())
+                && (StringUtils.width(text) < getWidth())) {
+
+                // Append case
+                appendChar(keypress.getKey().getChar());
+            } else if ((position < text.length())
+                && (StringUtils.width(text) < getWidth())) {
+
+                // Overwrite or insert a character
+                if (insertMode == false) {
+                    // Replace character
+                    text = text.substring(0, position)
+                            + codePointString(keypress.getKey().getChar())
+                            + text.substring(position + 1);
+                    screenPosition += StringUtils.width(text.codePointAt(position));
+                    position += Character.charCount(keypress.getKey().getChar());
+                } else {
+                    // Insert character
+                    insertChar(keypress.getKey().getChar());
+                }
+            } else if ((position < text.length())
+                && (StringUtils.width(text) >= getWidth())) {
+
+                // Multiple cases here
+                if ((fixed == true) && (insertMode == true)) {
+                    // Buffer is full, do nothing
+                } else if ((fixed == true) && (insertMode == false)) {
+                    // Overwrite the last character, maybe move position
+                    text = text.substring(0, position)
+                            + codePointString(keypress.getKey().getChar())
+                            + text.substring(position + 1);
+                    if (screenPosition < getWidth() - 1) {
+                        screenPosition += StringUtils.width(text.codePointAt(position));
+                        position += Character.charCount(keypress.getKey().getChar());
+                    }
+                } else if ((fixed == false) && (insertMode == false)) {
+                    // Overwrite the last character, definitely move position
+                    text = text.substring(0, position)
+                            + codePointString(keypress.getKey().getChar())
+                            + text.substring(position + 1);
+                    screenPosition += StringUtils.width(text.codePointAt(position));
+                    position += Character.charCount(keypress.getKey().getChar());
+                } else {
+                    if (position == text.length()) {
+                        // Append this character
+                        appendChar(keypress.getKey().getChar());
+                    } else {
+                        // Insert this character
+                        insertChar(keypress.getKey().getChar());
+                    }
+                }
+            } else {
+                assert (!fixed);
+
+                // Append this character
+                appendChar(keypress.getKey().getChar());
+            }
+            dispatch(false);
+            return;
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    /**
+     * Handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (command.equals(cmCut)) {
+            // Copy text to clipboard, and then remove it.
+            getClipboard().copyText(text);
+            setText("");
+            return;
+        }
+
+        if (command.equals(cmCopy)) {
+            // Copy text to clipboard.
+            getClipboard().copyText(text);
+            return;
+        }
+
+        if (command.equals(cmPaste)) {
+            // Paste text from clipboard.
+            String newText = getClipboard().pasteText();
+            if (newText != null) {
+                setText(newText);
+            }
+            return;
+        }
+
+        if (command.equals(cmClear)) {
+            // Remove text.
+            setText("");
+            return;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw the text field.
+     */
+    @Override
+    public void draw() {
+        CellAttributes fieldColor;
+
+        if (isAbsoluteActive()) {
+            fieldColor = getTheme().getColor(activeColorKey);
+        } else {
+            fieldColor = getTheme().getColor(inactiveColorKey);
+        }
+
+        int end = windowStart + getWidth();
+        if (end > StringUtils.width(text)) {
+            end = StringUtils.width(text);
+        }
+        hLineXY(0, 0, getWidth(), backgroundChar, fieldColor);
+        putStringXY(0, 0, text.substring(screenToTextPosition(windowStart),
+                screenToTextPosition(end)), fieldColor);
+
+        // Fix the cursor, it will be rendered by TApplication.drawAll().
+        updateCursor();
+    }
+
+    // ------------------------------------------------------------------------
+    // TField -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Convert a char (codepoint) to a string.
+     *
+     * @param ch the char
+     * @return the string
+     */
+    private String codePointString(final int ch) {
+        StringBuilder sb = new StringBuilder(1);
+        sb.append(Character.toChars(ch));
+        assert (Character.charCount(ch) == sb.length());
+        return sb.toString();
+    }
+
+    /**
+     * Get field background character.
+     *
+     * @return background character
+     */
+    public final int getBackgroundChar() {
+        return backgroundChar;
+    }
+
+    /**
+     * Set field background character.
+     *
+     * @param backgroundChar the background character
+     */
+    public void setBackgroundChar(final int backgroundChar) {
+        this.backgroundChar = backgroundChar;
+    }
+
+    /**
+     * Get field text.
+     *
+     * @return field text
+     */
+    public final String getText() {
+        return text;
+    }
+
+    /**
+     * Set field text.
+     *
+     * @param text the new field text
+     */
+    public void setText(final String text) {
+        assert (text != null);
+        this.text = text;
+        position = 0;
+        screenPosition = 0;
+        windowStart = 0;
+        if ((fixed == true) && (this.text.length() > getWidth())) {
+            this.text = this.text.substring(0, getWidth());
+        }
+    }
+
+    /**
+     * Dispatch to the action function.
+     *
+     * @param enter if true, the user pressed Enter, else this was an update
+     * to the text.
+     */
+    protected void dispatch(final boolean enter) {
+        if (enter) {
+            if (enterAction != null) {
+                enterAction.DO(this);
+            }
+        } else {
+            if (updateAction != null) {
+                updateAction.DO(this);
+            }
+        }
+    }
+
+    /**
+     * Determine string position from screen position.
+     *
+     * @param screenPosition the position on screen
+     * @return the equivalent position in text
+     */
+    protected int screenToTextPosition(final int screenPosition) {
+        if (screenPosition == 0) {
+            return 0;
+        }
+
+        int n = 0;
+        for (int i = 0; i < text.length(); i++) {
+            n += StringUtils.width(text.codePointAt(i));
+            if (n >= screenPosition) {
+                return i + 1;
+            }
+        }
+        // screenPosition exceeds the available text length.
+        throw new IndexOutOfBoundsException("screenPosition " + screenPosition +
+            " exceeds available text length " + text.length());
+    }
+
+    /**
+     * Update the visible cursor position to match the location of position
+     * and windowStart.
+     */
+    protected void updateCursor() {
+        if ((screenPosition > getWidth()) && fixed) {
+            setCursorX(getWidth());
+        } else if ((screenPosition - windowStart >= getWidth()) && !fixed) {
+            setCursorX(getWidth() - 1);
+        } else {
+            setCursorX(screenPosition - windowStart);
+        }
+    }
+
+    /**
+     * Normalize windowStart such that most of the field data if visible.
+     */
+    protected void normalizeWindowStart() {
+        if (fixed) {
+            // windowStart had better be zero, there is nothing to do here.
+            assert (windowStart == 0);
+            return;
+        }
+        windowStart = screenPosition - (getWidth() - 1);
+        if (windowStart < 0) {
+            windowStart = 0;
+        }
+
+        updateCursor();
+    }
+
+    /**
+     * Append char to the end of the field.
+     *
+     * @param ch char to append
+     */
+    protected void appendChar(final int ch) {
+        // Append the LAST character
+        text += codePointString(ch);
+        position += Character.charCount(ch);
+        screenPosition += StringUtils.width(ch);
+
+        assert (position == text.length());
+
+        if (fixed) {
+            if (screenPosition >= getWidth()) {
+                position -= Character.charCount(ch);
+                screenPosition -= StringUtils.width(ch);
+            }
+        } else {
+            if ((screenPosition - windowStart) >= getWidth()) {
+                windowStart++;
+            }
+        }
+    }
+
+    /**
+     * Insert char somewhere in the middle of the field.
+     *
+     * @param ch char to append
+     */
+    protected void insertChar(final int ch) {
+        text = text.substring(0, position) + codePointString(ch)
+                + text.substring(position);
+        position += Character.charCount(ch);
+        screenPosition += StringUtils.width(ch);
+        if ((screenPosition - windowStart) == getWidth()) {
+            assert (!fixed);
+            windowStart++;
+        }
+    }
+
+    /**
+     * Position the cursor at the first column.  The field may adjust the
+     * window start to show as much of the field as possible.
+     */
+    public void home() {
+        position = 0;
+        screenPosition = 0;
+        windowStart = 0;
+    }
+
+    /**
+     * Set the editing position to the last filled character.  The field may
+     * adjust the window start to show as much of the field as possible.
+     */
+    public void end() {
+        position = text.length();
+        screenPosition = StringUtils.width(text);
+        if (fixed == true) {
+            if (screenPosition >= getWidth()) {
+                position -= Character.charCount(text.codePointBefore(position));
+                screenPosition = StringUtils.width(text) - 1;
+             }
+        } else {
+            windowStart = StringUtils.width(text) - getWidth() + 1;
+            if (windowStart < 0) {
+                windowStart = 0;
+            }
+        }
+    }
+
+    /**
+     * Set the editing position.  The field may adjust the window start to
+     * show as much of the field as possible.
+     *
+     * @param position the new position
+     * @throws IndexOutOfBoundsException if position is outside the range of
+     * the available text
+     */
+    public void setPosition(final int position) {
+        if ((position < 0) || (position >= text.length())) {
+            throw new IndexOutOfBoundsException("Max length is " +
+                text.length() + ", requested position " + position);
+        }
+        this.position = position;
+        normalizeWindowStart();
+    }
+
+    /**
+     * Set the active color key.
+     *
+     * @param activeColorKey ColorTheme key color to use when this field is
+     * active
+     */
+    public void setActiveColorKey(final String activeColorKey) {
+        this.activeColorKey = activeColorKey;
+    }
+
+    /**
+     * Set the inactive color key.
+     *
+     * @param inactiveColorKey ColorTheme key color to use when this field is
+     * inactive
+     */
+    public void setInactiveColorKey(final String inactiveColorKey) {
+        this.inactiveColorKey = inactiveColorKey;
+    }
+
+    /**
+     * Set the action to perform when the user presses enter.
+     *
+     * @param action the action to perform when the user presses enter
+     */
+    public void setEnterAction(final TAction action) {
+        enterAction = action;
+    }
+
+    /**
+     * Set the action to perform when the field is updated.
+     *
+     * @param action the action to perform when the field is updated
+     */
+    public void setUpdateAction(final TAction action) {
+        updateAction = action;
+    }
+
+    // ------------------------------------------------------------------------
+    // EditMenuUser -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if the cut menu item should be enabled.
+     *
+     * @return true if the cut menu item should be enabled
+     */
+    public boolean isEditMenuCut() {
+        return true;
+    }
+
+    /**
+     * Check if the copy menu item should be enabled.
+     *
+     * @return true if the copy menu item should be enabled
+     */
+    public boolean isEditMenuCopy() {
+        return true;
+    }
+
+    /**
+     * Check if the paste menu item should be enabled.
+     *
+     * @return true if the paste menu item should be enabled
+     */
+    public boolean isEditMenuPaste() {
+        return true;
+    }
+
+    /**
+     * Check if the clear menu item should be enabled.
+     *
+     * @return true if the clear menu item should be enabled
+     */
+    public boolean isEditMenuClear() {
+        return true;
+    }
+
+}
diff --git a/src/jexer/TFileOpenBox.java b/src/jexer/TFileOpenBox.java
new file mode 100644 (file)
index 0000000..a2cc0cf
--- /dev/null
@@ -0,0 +1,416 @@
+/*
+ * 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.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.ResourceBundle;
+
+import jexer.backend.SwingTerminal;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import jexer.ttree.TDirectoryTreeItem;
+import jexer.ttree.TTreeItem;
+import jexer.ttree.TTreeViewWidget;
+import static jexer.TKeypress.*;
+
+/**
+ * TFileOpenBox is a system-modal dialog for selecting a file to open.  Call
+ * it like:
+ *
+ * <pre>
+ * {@code
+ *     filename = fileOpenBox("/path/to/file.ext",
+ *         TFileOpenBox.Type.OPEN);
+ *     if (filename != null) {
+ *         ... the user selected a file, go open it ...
+ *     }
+ * }
+ * </pre>
+ *
+ */
+public class TFileOpenBox extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TFileOpenBox.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * TFileOpenBox can be called for either Open or Save actions.
+     */
+    public enum Type {
+        /**
+         * Button will be labeled "Open".
+         */
+        OPEN,
+
+        /**
+         * Button will be labeled "Save".
+         */
+        SAVE,
+
+        /**
+         * Button will be labeled "Select".
+         */
+        SELECT
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * String to return, or null if the user canceled.
+     */
+    private String filename = null;
+
+    /**
+     * The left-side tree view pane.
+     */
+    private TTreeViewWidget treeView;
+
+    /**
+     * The data behind treeView.
+     */
+    private TDirectoryTreeItem treeViewRoot;
+
+    /**
+     * The right-side directory list pane.
+     */
+    private TDirectoryList directoryList;
+
+    /**
+     * The top row text field.
+     */
+    private TField entryField;
+
+    /**
+     * The Open or Save button.
+     */
+    private TButton openButton;
+
+    /**
+     * The type of box this is (OPEN, SAVE, or SELECT).
+     */
+    private Type type = Type.OPEN;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  The file open box will be centered on screen.
+     *
+     * @param application the TApplication that manages this window
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @throws IOException of a java.io operation throws
+     */
+    public TFileOpenBox(final TApplication application, final String path,
+        final Type type) throws IOException {
+
+        this(application, path, type, null);
+    }
+
+    /**
+     * Public constructor.  The file open box will be centered on screen.
+     *
+     * @param application the TApplication that manages this window
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @param filters a list of strings that files must match to be displayed
+     * @throws IOException of a java.io operation throws
+     */
+    public TFileOpenBox(final TApplication application, final String path,
+        final Type type, final List<String> filters) throws IOException {
+
+        // Register with the TApplication
+        super(application, "", 0, 0, 76, 22, MODAL);
+
+        // Add text field
+        entryField = addField(1, 1, getWidth() - 4, false,
+            (new File(path)).getCanonicalPath(),
+            new TAction() {
+                public void DO() {
+                    try {
+                        checkFilename(entryField.getText());
+                    } catch (IOException e) {
+                        // If the backend is Swing, we can emit the stack
+                        // trace to stderr.  Otherwise, just squash it.
+                        if (getScreen() instanceof SwingTerminal) {
+                            e.printStackTrace();
+                        }
+                    }
+                }
+            }, null);
+        entryField.onKeypress(new TKeypressEvent(kbEnd));
+
+        // Add directory treeView
+        treeView = addTreeViewWidget(1, 3, 30, getHeight() - 6,
+            new TAction() {
+                public void DO() {
+                    TTreeItem item = treeView.getSelected();
+                    File selectedDir = ((TDirectoryTreeItem) item).getFile();
+                    try {
+                        directoryList.setPath(selectedDir.getCanonicalPath());
+                        entryField.setText(selectedDir.getCanonicalPath());
+                        if (type == Type.OPEN) {
+                            openButton.setEnabled(false);
+                        }
+                        activate(treeView);
+                    } catch (IOException e) {
+                        // If the backend is Swing, we can emit the stack
+                        // trace to stderr.  Otherwise, just squash it.
+                        if (getScreen() instanceof SwingTerminal) {
+                            e.printStackTrace();
+                        }
+                    }
+                }
+            }
+        );
+        treeViewRoot = new TDirectoryTreeItem(treeView, path, true);
+
+        // Add directory files list
+        directoryList = addDirectoryList(path, 34, 3, 28, getHeight() - 6,
+            new TAction() {
+                public void DO() {
+                    try {
+                        File newPath = directoryList.getPath();
+                        entryField.setText(newPath.getCanonicalPath());
+                        entryField.onKeypress(new TKeypressEvent(kbEnd));
+                        openButton.setEnabled(true);
+                        activate(entryField);
+                        checkFilename(entryField.getText());
+                    } catch (IOException e) {
+                        // If the backend is Swing, we can emit the stack
+                        // trace to stderr.  Otherwise, just squash it.
+                        if (getScreen() instanceof SwingTerminal) {
+                            e.printStackTrace();
+                        }
+                    }
+                }
+            },
+            new TAction() {
+                public void DO() {
+                    try {
+                        File newPath = directoryList.getPath();
+                        entryField.setText(newPath.getCanonicalPath());
+                        entryField.onKeypress(new TKeypressEvent(kbEnd));
+                        openButton.setEnabled(true);
+                        activate(entryField);
+                    } catch (IOException e) {
+                        // If the backend is Swing, we can emit the stack
+                        // trace to stderr.  Otherwise, just squash it.
+                        if (getScreen() instanceof SwingTerminal) {
+                            e.printStackTrace();
+                        }
+                    }
+                }
+            },
+            filters);
+
+        String openLabel = "";
+        switch (type) {
+        case OPEN:
+            openLabel = i18n.getString("openButton");
+            setTitle(i18n.getString("openTitle"));
+            break;
+        case SAVE:
+            openLabel = i18n.getString("saveButton");
+            setTitle(i18n.getString("saveTitle"));
+            break;
+        case SELECT:
+            openLabel = i18n.getString("selectButton");
+            setTitle(i18n.getString("selectTitle"));
+            break;
+        default:
+            throw new IllegalArgumentException("Invalid type: " + type);
+        }
+        this.type = type;
+
+        // Setup button actions
+        openButton = addButton(openLabel, this.getWidth() - 12, 3,
+            new TAction() {
+                public void DO() {
+                    try {
+                        checkFilename(entryField.getText());
+                    } catch (IOException e) {
+                        // If the backend is Swing, we can emit the stack
+                        // trace to stderr.  Otherwise, just squash it.
+                        if (getScreen() instanceof SwingTerminal) {
+                            e.printStackTrace();
+                        }
+                    }
+                }
+            }
+        );
+        if (type == Type.OPEN) {
+            openButton.setEnabled(false);
+        }
+
+        addButton(i18n.getString("cancelButton"), getWidth() - 12, 5,
+            new TAction() {
+                public void DO() {
+                    filename = null;
+                    getApplication().closeWindow(TFileOpenBox.this);
+                }
+            }
+        );
+
+        // Default to the directory list
+        activate(directoryList);
+
+        // Set the secondaryFiber to run me
+        getApplication().enableSecondaryEventReceiver(this);
+
+        // Yield to the secondary thread.  When I come back from the
+        // constructor response will already be set.
+        getApplication().yield();
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        // Escape - behave like cancel
+        if (keypress.equals(kbEsc)) {
+            // Close window
+            filename = null;
+            getApplication().closeWindow(this);
+            return;
+        }
+
+        if (treeView.isActive()) {
+            if ((keypress.equals(kbEnter))
+                || (keypress.equals(kbUp))
+                || (keypress.equals(kbDown))
+                || (keypress.equals(kbPgUp))
+                || (keypress.equals(kbPgDn))
+                || (keypress.equals(kbHome))
+                || (keypress.equals(kbEnd))
+            ) {
+                // Tree view will be changing, update the directory list.
+                super.onKeypress(keypress);
+
+                // This is the same action as treeView's enter.
+                TTreeItem item = treeView.getSelected();
+                File selectedDir = ((TDirectoryTreeItem) item).getFile();
+                try {
+                    directoryList.setPath(selectedDir.getCanonicalPath());
+                    if (type == Type.OPEN) {
+                        openButton.setEnabled(false);
+                    }
+                    activate(treeView);
+                } catch (IOException e) {
+                    // If the backend is Swing, we can emit the stack trace
+                    // to stderr.  Otherwise, just squash it.
+                    if (getScreen() instanceof SwingTerminal) {
+                        e.printStackTrace();
+                    }
+                }
+                return;
+            }
+        }
+
+        // Pass to my parent
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw me on screen.
+     */
+    @Override
+    public void draw() {
+        super.draw();
+        vLineXY(33, 4, getHeight() - 6, GraphicsChars.WINDOW_SIDE,
+            getBackground());
+    }
+
+    // ------------------------------------------------------------------------
+    // TFileOpenBox -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the return string.
+     *
+     * @return the filename the user selected, or null if they canceled.
+     */
+    public String getFilename() {
+        return filename;
+    }
+
+    /**
+     * See if there is a valid filename to return.  If the filename is a
+     * directory, then
+     *
+     * @param newFilename the filename to check and return
+     * @throws IOException of a java.io operation throws
+     */
+    private void checkFilename(final String newFilename) throws IOException {
+        File newFile = new File(newFilename);
+        if (newFile.exists()) {
+            if (newFile.isFile() || (type == Type.SELECT)) {
+                filename = newFilename;
+                getApplication().closeWindow(this);
+                return;
+            }
+            if (newFile.isDirectory()) {
+                treeViewRoot = new TDirectoryTreeItem(treeView,
+                    newFilename, true);
+                treeView.setTreeRoot(treeViewRoot, true);
+                if (type == Type.OPEN) {
+                    openButton.setEnabled(false);
+                }
+                directoryList.setPath(newFilename);
+            }
+        } else if (type != Type.OPEN) {
+            filename = newFilename;
+            getApplication().closeWindow(this);
+            return;
+        }
+    }
+
+}
diff --git a/src/jexer/TFileOpenBox.properties b/src/jexer/TFileOpenBox.properties
new file mode 100644 (file)
index 0000000..ef40e86
--- /dev/null
@@ -0,0 +1,7 @@
+openButton=\ &Open\ 
+openTitle=Open File...
+saveButton=\ &Save\ 
+saveTitle=Save File...
+cancelButton=&Cancel
+selectButton=S&elect
+selectTitle=Select File...
diff --git a/src/jexer/TFontChooserWindow.java b/src/jexer/TFontChooserWindow.java
new file mode 100644 (file)
index 0000000..62eabb6
--- /dev/null
@@ -0,0 +1,628 @@
+/*
+ * 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.awt.Font;
+import java.awt.GraphicsEnvironment;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.ResourceBundle;
+
+import jexer.backend.ECMA48Terminal;
+import jexer.backend.SwingTerminal;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TFontChooserWindow provides an easy UI for users to alter the running
+ * font.
+ *
+ */
+public class TFontChooserWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TFontChooserWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The Swing screen.
+     */
+    private SwingTerminal terminal = null;
+
+    /**
+     * The ECMA48 screen.
+     */
+    private ECMA48Terminal ecmaTerminal = null;
+
+    /**
+     * The font name.
+     */
+    private TComboBox fontName;
+
+    /**
+     * The font size.
+     */
+    private TField fontSize;
+
+    /**
+     * The X text adjustment.
+     */
+    private TField textAdjustX;
+
+    /**
+     * The Y text adjustment.
+     */
+    private TField textAdjustY;
+
+    /**
+     * The height text adjustment.
+     */
+    private TField textAdjustHeight;
+
+    /**
+     * The width text adjustment.
+     */
+    private TField textAdjustWidth;
+
+    /**
+     * The sixel palette size.
+     */
+    private TComboBox sixelPaletteSize;
+
+    /**
+     * The original font size.
+     */
+    private int oldFontSize = 20;
+
+    /**
+     * The original font.
+     */
+    private Font oldFont = null;
+
+    /**
+     * The original text adjust X value.
+     */
+    private int oldTextAdjustX = 0;
+
+    /**
+     * The original text adjust Y value.
+     */
+    private int oldTextAdjustY = 0;
+
+    /**
+     * The original text adjust height value.
+     */
+    private int oldTextAdjustHeight = 0;
+
+    /**
+     * The original text adjust width value.
+     */
+    private int oldTextAdjustWidth = 0;
+
+    /**
+     * The original sixel palette (number of colors) value.
+     */
+    private int oldSixelPaletteSize = 1024;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  The window will be centered on screen.
+     *
+     * @param application the TApplication that manages this window
+     */
+    public TFontChooserWindow(final TApplication application) {
+
+        // Register with the TApplication
+        super(application, i18n.getString("windowTitle"), 0, 0, 60, 21, MODAL);
+
+        // Add shortcut text
+        newStatusBar(i18n.getString("statusBar"));
+
+        if (getScreen() instanceof SwingTerminal) {
+            terminal = (SwingTerminal) getScreen();
+        }
+        if (getScreen() instanceof ECMA48Terminal) {
+            ecmaTerminal = (ECMA48Terminal) getScreen();
+        }
+
+        addLabel(i18n.getString("fontName"), 1, 1, "ttext", false);
+        addLabel(i18n.getString("fontSize"), 1, 2, "ttext", false);
+        addLabel(i18n.getString("textAdjustX"), 1, 4, "ttext", false);
+        addLabel(i18n.getString("textAdjustY"), 1, 5, "ttext", false);
+        addLabel(i18n.getString("textAdjustHeight"), 1, 6, "ttext", false);
+        addLabel(i18n.getString("textAdjustWidth"), 1, 7, "ttext", false);
+        addLabel(i18n.getString("sixelPaletteSize"), 1, 9, "ttext", false);
+
+        int col = 21;
+        if (terminal == null) {
+            // Non-Swing case: we can't change anything
+            addLabel(i18n.getString("unavailable"), col, 1);
+            addLabel(i18n.getString("unavailable"), col, 2);
+            addLabel(i18n.getString("unavailable"), col, 4);
+            addLabel(i18n.getString("unavailable"), col, 5);
+            addLabel(i18n.getString("unavailable"), col, 6);
+            addLabel(i18n.getString("unavailable"), col, 7);
+        }
+        if (ecmaTerminal == null) {
+            addLabel(i18n.getString("unavailable"), col, 9);
+        }
+        if (ecmaTerminal != null) {
+            oldSixelPaletteSize = ecmaTerminal.getSixelPaletteSize();
+
+            String [] sixelSizes = { "2", "256", "512", "1024", "2048" };
+            List<String> sizes = new ArrayList<String>();
+            sizes.addAll(Arrays.asList(sixelSizes));
+            sixelPaletteSize = addComboBox(col, 9, 10, sizes, 0, 6,
+                new TAction() {
+                    public void DO() {
+                        try {
+                            ecmaTerminal.setSixelPaletteSize(Integer.parseInt(
+                                sixelPaletteSize.getText()));
+                        } catch (NumberFormatException e) {
+                            // SQUASH
+                        }
+                    }
+                }
+            );
+            sixelPaletteSize.setText(Integer.toString(oldSixelPaletteSize));
+        }
+
+        if (terminal != null) {
+            oldFont = terminal.getFont();
+            oldFontSize = terminal.getFontSize();
+            oldTextAdjustX = terminal.getTextAdjustX();
+            oldTextAdjustY = terminal.getTextAdjustY();
+            oldTextAdjustHeight = terminal.getTextAdjustHeight();
+            oldTextAdjustWidth = terminal.getTextAdjustWidth();
+
+            String [] fontNames = GraphicsEnvironment.
+                getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
+            List<String> fonts = new ArrayList<String>();
+            fonts.add(0, i18n.getString("builtInTerminus"));
+            fonts.addAll(Arrays.asList(fontNames));
+            fontName = addComboBox(col, 1, 25, fonts, 0, 10,
+                new TAction() {
+                    public void DO() {
+                        if (fontName.getText().equals(i18n.
+                                getString("builtInTerminus"))) {
+
+                            terminal.setDefaultFont();
+                        } else {
+                            terminal.setFont(new Font(fontName.getText(),
+                                    Font.PLAIN, terminal.getFontSize()));
+                            fontSize.setText(Integer.toString(
+                                terminal.getFontSize()));
+                            textAdjustX.setText(Integer.toString(
+                                terminal.getTextAdjustX()));
+                            textAdjustY.setText(Integer.toString(
+                                terminal.getTextAdjustY()));
+                            textAdjustHeight.setText(Integer.toString(
+                                terminal.getTextAdjustHeight()));
+                            textAdjustWidth.setText(Integer.toString(
+                                terminal.getTextAdjustWidth()));
+                        }
+                    }
+                }
+            );
+
+            // Font size
+            fontSize = addField(col, 2, 3, true,
+                Integer.toString(terminal.getFontSize()),
+                new TAction() {
+                    public void DO() {
+                        int currentSize = terminal.getFontSize();
+                        int newSize = currentSize;
+                        try {
+                            newSize = Integer.parseInt(fontSize.getText());
+                        } catch (NumberFormatException e) {
+                            fontSize.setText(Integer.toString(currentSize));
+                        }
+                        if (newSize != currentSize) {
+                            terminal.setFontSize(newSize);
+                            textAdjustX.setText(Integer.toString(
+                                terminal.getTextAdjustX()));
+                            textAdjustY.setText(Integer.toString(
+                                terminal.getTextAdjustY()));
+                            textAdjustHeight.setText(Integer.toString(
+                                terminal.getTextAdjustHeight()));
+                            textAdjustWidth.setText(Integer.toString(
+                                terminal.getTextAdjustWidth()));
+                        }
+                    }
+                },
+                null);
+
+            addSpinner(col + 3, 2,
+                new TAction() {
+                    public void DO() {
+                        int currentSize = terminal.getFontSize();
+                        int newSize = currentSize;
+                        try {
+                            newSize = Integer.parseInt(fontSize.getText());
+                            newSize++;
+                        } catch (NumberFormatException e) {
+                            fontSize.setText(Integer.toString(currentSize));
+                        }
+                        fontSize.setText(Integer.toString(newSize));
+                        if (newSize != currentSize) {
+                            terminal.setFontSize(newSize);
+                            textAdjustX.setText(Integer.toString(
+                                terminal.getTextAdjustX()));
+                            textAdjustY.setText(Integer.toString(
+                                terminal.getTextAdjustY()));
+                            textAdjustHeight.setText(Integer.toString(
+                                terminal.getTextAdjustHeight()));
+                            textAdjustWidth.setText(Integer.toString(
+                                terminal.getTextAdjustWidth()));
+                        }
+                    }
+                },
+                new TAction() {
+                    public void DO() {
+                        int currentSize = terminal.getFontSize();
+                        int newSize = currentSize;
+                        try {
+                            newSize = Integer.parseInt(fontSize.getText());
+                            newSize--;
+                        } catch (NumberFormatException e) {
+                            fontSize.setText(Integer.toString(currentSize));
+                        }
+                        fontSize.setText(Integer.toString(newSize));
+                        if (newSize != currentSize) {
+                            terminal.setFontSize(newSize);
+                            textAdjustX.setText(Integer.toString(
+                                terminal.getTextAdjustX()));
+                            textAdjustY.setText(Integer.toString(
+                                terminal.getTextAdjustY()));
+                            textAdjustHeight.setText(Integer.toString(
+                                terminal.getTextAdjustHeight()));
+                            textAdjustWidth.setText(Integer.toString(
+                                terminal.getTextAdjustWidth()));
+                        }
+                    }
+                }
+            );
+
+            // textAdjustX
+            textAdjustX = addField(col, 4, 3, true,
+                Integer.toString(terminal.getTextAdjustX()),
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustX();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustX.getText());
+                        } catch (NumberFormatException e) {
+                            textAdjustX.setText(Integer.toString(currentAdjust));
+                        }
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustX(newAdjust);
+                        }
+                    }
+                },
+                null);
+
+            addSpinner(col + 3, 4,
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustX();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustX.getText());
+                            newAdjust++;
+                        } catch (NumberFormatException e) {
+                            textAdjustX.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustX.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustX(newAdjust);
+                        }
+                    }
+                },
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustX();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustX.getText());
+                            newAdjust--;
+                        } catch (NumberFormatException e) {
+                            textAdjustX.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustX.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustX(newAdjust);
+                        }
+                    }
+                }
+            );
+
+            // textAdjustY
+            textAdjustY = addField(col, 5, 3, true,
+                Integer.toString(terminal.getTextAdjustY()),
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustY();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustY.getText());
+                        } catch (NumberFormatException e) {
+                            textAdjustY.setText(Integer.toString(currentAdjust));
+                        }
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustY(newAdjust);
+                        }
+                    }
+                },
+                null);
+
+            addSpinner(col + 3, 5,
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustY();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustY.getText());
+                            newAdjust++;
+                        } catch (NumberFormatException e) {
+                            textAdjustY.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustY.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustY(newAdjust);
+                        }
+                    }
+                },
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustY();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustY.getText());
+                            newAdjust--;
+                        } catch (NumberFormatException e) {
+                            textAdjustY.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustY.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustY(newAdjust);
+                        }
+                    }
+                }
+            );
+
+            // textAdjustHeight
+            textAdjustHeight = addField(col, 6, 3, true,
+                Integer.toString(terminal.getTextAdjustHeight()),
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustHeight();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustHeight.getText());
+                        } catch (NumberFormatException e) {
+                            textAdjustHeight.setText(Integer.toString(currentAdjust));
+                        }
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustHeight(newAdjust);
+                        }
+                    }
+                },
+                null);
+
+            addSpinner(col + 3, 6,
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustHeight();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustHeight.getText());
+                            newAdjust++;
+                        } catch (NumberFormatException e) {
+                            textAdjustHeight.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustHeight.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustHeight(newAdjust);
+                        }
+                    }
+                },
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustHeight();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustHeight.getText());
+                            newAdjust--;
+                        } catch (NumberFormatException e) {
+                            textAdjustHeight.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustHeight.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustHeight(newAdjust);
+                        }
+                    }
+                }
+            );
+
+            // textAdjustWidth
+            textAdjustWidth = addField(col, 7, 3, true,
+                Integer.toString(terminal.getTextAdjustWidth()),
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustWidth();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustWidth.getText());
+                        } catch (NumberFormatException e) {
+                            textAdjustWidth.setText(Integer.toString(currentAdjust));
+                        }
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustWidth(newAdjust);
+                        }
+                    }
+                },
+                null);
+
+            addSpinner(col + 3, 7,
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustWidth();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustWidth.getText());
+                            newAdjust++;
+                        } catch (NumberFormatException e) {
+                            textAdjustWidth.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustWidth.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustWidth(newAdjust);
+                        }
+                    }
+                },
+                new TAction() {
+                    public void DO() {
+                        int currentAdjust = terminal.getTextAdjustWidth();
+                        int newAdjust = currentAdjust;
+                        try {
+                            newAdjust = Integer.parseInt(textAdjustWidth.getText());
+                            newAdjust--;
+                        } catch (NumberFormatException e) {
+                            textAdjustWidth.setText(Integer.toString(currentAdjust));
+                        }
+                        textAdjustWidth.setText(Integer.toString(newAdjust));
+                        if (newAdjust != currentAdjust) {
+                            terminal.setTextAdjustWidth(newAdjust);
+                        }
+                    }
+                }
+            );
+
+        }
+
+        addButton(i18n.getString("okButton"), 18, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    // Close window.
+                    TFontChooserWindow.this.close();
+                }
+            });
+
+        TButton cancelButton = addButton(i18n.getString("cancelButton"),
+            30, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    // Restore old values, then close the window.
+                    if (terminal != null) {
+                        terminal.setFont(oldFont);
+                        terminal.setFontSize(oldFontSize);
+                        terminal.setTextAdjustX(oldTextAdjustX);
+                        terminal.setTextAdjustY(oldTextAdjustY);
+                        terminal.setTextAdjustHeight(oldTextAdjustHeight);
+                        terminal.setTextAdjustWidth(oldTextAdjustWidth);
+                    }
+                    if (ecmaTerminal != null) {
+                        ecmaTerminal.setSixelPaletteSize(oldSixelPaletteSize);
+                    }
+                    TFontChooserWindow.this.close();
+                }
+            });
+
+        // Save this for last: make the cancel button default action.
+        activate(cancelButton);
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        // Escape - behave like cancel
+        if (keypress.equals(kbEsc)) {
+            // Restore old values, then close the window.
+            if (terminal != null) {
+                terminal.setFont(oldFont);
+                terminal.setFontSize(oldFontSize);
+            }
+            if (ecmaTerminal != null) {
+                ecmaTerminal.setSixelPaletteSize(oldSixelPaletteSize);
+            }
+            getApplication().closeWindow(this);
+            return;
+        }
+
+        // Pass to my parent
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw me on screen.
+     */
+    @Override
+    public void draw() {
+        super.draw();
+
+        int left = 34;
+        CellAttributes color = getTheme().getColor("ttext");
+        drawBox(left, 6, left + 24, 14, color, color, 3, false);
+        putStringXY(left + 2, 6, i18n.getString("sample"), color);
+        for (int i = 7; i < 13; i++) {
+            hLineXY(left + 1, i, 22, GraphicsChars.HATCH, color);
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TFontChooserWindow -----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+}
diff --git a/src/jexer/TFontChooserWindow.properties b/src/jexer/TFontChooserWindow.properties
new file mode 100644 (file)
index 0000000..4ab274e
--- /dev/null
@@ -0,0 +1,17 @@
+windowTitle=Screen
+okButton=\ \ &OK\ \ 
+cancelButton=&Cancel
+statusBar=Select Screen Options
+
+fontName=Font name:
+fontSize=Font size:
+textAdjustX=X adjust:
+textAdjustY=Y adjust:
+textAdjustHeight=Height adjust:
+textAdjustWidth=Width adjust:
+
+sixelPaletteSize=Sixel Palette Size:
+
+unavailable=Unavailable
+builtInTerminus=Built-In Terminus
+sample=\ Sample Window\ 
diff --git a/src/jexer/THScroller.java b/src/jexer/THScroller.java
new file mode 100644 (file)
index 0000000..a07bcd7
--- /dev/null
@@ -0,0 +1,407 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TMouseEvent;
+
+/**
+ * THScroller implements a simple horizontal scroll bar.
+ */
+public class THScroller extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Value that corresponds to being on the left edge of the scroll bar.
+     */
+    private int leftValue = 0;
+
+    /**
+     * Value that corresponds to being on the right edge of the scroll bar.
+     */
+    private int rightValue = 100;
+
+    /**
+     * Current value of the scroll.
+     */
+    private int value = 0;
+
+    /**
+     * The increment for clicking on an arrow.
+     */
+    private int smallChange = 1;
+
+    /**
+     * The increment for clicking in the bar between the box and an arrow.
+     */
+    private int bigChange = 20;
+
+    /**
+     * When true, the user is dragging the scroll box.
+     */
+    private boolean inScroll = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width height of scroll bar
+     */
+    public THScroller(final TWidget parent, final int x, final int y,
+        final int width) {
+
+        // Set parent and window
+        super(parent, x, y, width, 1);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+
+        if (inScroll) {
+            inScroll = false;
+            return;
+        }
+
+        if (rightValue == leftValue) {
+            return;
+        }
+
+        if ((mouse.getX() == 0)
+            && (mouse.getY() == 0)
+        ) {
+            // Clicked on the left arrow
+            decrement();
+            return;
+        }
+
+        if ((mouse.getY() == 0)
+            && (mouse.getX() == getWidth() - 1)
+        ) {
+            // Clicked on the right arrow
+            increment();
+            return;
+        }
+
+        if ((mouse.getY() == 0)
+            && (mouse.getX() > 0)
+            && (mouse.getX() < boxPosition())
+        ) {
+            // Clicked between the left arrow and the box
+            value -= bigChange;
+            if (value < leftValue) {
+                value = leftValue;
+            }
+            return;
+        }
+
+        if ((mouse.getY() == 0)
+            && (mouse.getX() > boxPosition())
+            && (mouse.getX() < getWidth() - 1)
+        ) {
+            // Clicked between the box and the right arrow
+            value += bigChange;
+            if (value > rightValue) {
+                value = rightValue;
+            }
+            return;
+        }
+    }
+
+    /**
+     * Handle mouse movement events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+
+        if (rightValue == leftValue) {
+            inScroll = false;
+            return;
+        }
+
+        if ((mouse.isMouse1())
+            && (inScroll)
+            && (mouse.getX() > 0)
+            && (mouse.getX() < getWidth() - 1)
+        ) {
+            // Recompute value based on new box position
+            value = (rightValue - leftValue)
+                * (mouse.getX()) / (getWidth() - 3) + leftValue;
+            if (value > rightValue) {
+                value = rightValue;
+            }
+            if (value < leftValue) {
+                value = leftValue;
+            }
+            return;
+        }
+        inScroll = false;
+    }
+
+    /**
+     * Handle mouse button press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (rightValue == leftValue) {
+            inScroll = false;
+            return;
+        }
+
+        if ((mouse.getY() == 0)
+            && (mouse.getX() == boxPosition())
+        ) {
+            inScroll = true;
+            return;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw a horizontal scroll bar.
+     */
+    @Override
+    public void draw() {
+        CellAttributes arrowColor = getTheme().getColor("tscroller.arrows");
+        CellAttributes barColor = getTheme().getColor("tscroller.bar");
+        putCharXY(0, 0, GraphicsChars.CP437[0x11], arrowColor);
+        putCharXY(getWidth() - 1, 0, GraphicsChars.CP437[0x10], arrowColor);
+
+        // Place the box
+        if (rightValue > leftValue) {
+            hLineXY(1, 0, getWidth() - 2, GraphicsChars.CP437[0xB1], barColor);
+            putCharXY(boxPosition(), 0, GraphicsChars.BOX, arrowColor);
+        } else {
+            hLineXY(1, 0, getWidth() - 2, GraphicsChars.HATCH, barColor);
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // THScroller -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the value that corresponds to being on the left edge of the scroll
+     * bar.
+     *
+     * @return the scroll value
+     */
+    public int getLeftValue() {
+        return leftValue;
+    }
+
+    /**
+     * Set the value that corresponds to being on the left edge of the
+     * scroll bar.
+     *
+     * @param leftValue the new scroll value
+     */
+    public void setLeftValue(final int leftValue) {
+        this.leftValue = leftValue;
+    }
+
+    /**
+     * Get the value that corresponds to being on the right edge of the
+     * scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getRightValue() {
+        return rightValue;
+    }
+
+    /**
+     * Set the value that corresponds to being on the right edge of the
+     * scroll bar.
+     *
+     * @param rightValue the new scroll value
+     */
+    public void setRightValue(final int rightValue) {
+        this.rightValue = rightValue;
+    }
+
+    /**
+     * Get current value of the scroll.
+     *
+     * @return the scroll value
+     */
+    public int getValue() {
+        return value;
+    }
+
+    /**
+     * Set current value of the scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setValue(final int value) {
+        this.value = value;
+    }
+
+    /**
+     * Get the increment for clicking on an arrow.
+     *
+     * @return the increment value
+     */
+    public int getSmallChange() {
+        return smallChange;
+    }
+
+    /**
+     * Set the increment for clicking on an arrow.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setSmallChange(final int smallChange) {
+        this.smallChange = smallChange;
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow.
+     *
+     * @return the increment value
+     */
+    public int getBigChange() {
+        return bigChange;
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setBigChange(final int bigChange) {
+        this.bigChange = bigChange;
+    }
+
+    /**
+     * Compute the position of the scroll box (a.k.a. grip, thumb).
+     *
+     * @return Y position of the box, between 1 and width - 2
+     */
+    private int boxPosition() {
+        return (getWidth() - 3) * (value - leftValue) / (rightValue - leftValue) + 1;
+    }
+
+    /**
+     * Perform a small step change left.
+     */
+    public void decrement() {
+        if (leftValue == rightValue) {
+            return;
+        }
+        value -= smallChange;
+        if (value < leftValue) {
+            value = leftValue;
+        }
+    }
+
+    /**
+     * Perform a small step change right.
+     */
+    public void increment() {
+        if (leftValue == rightValue) {
+            return;
+        }
+        value += smallChange;
+        if (value > rightValue) {
+            value = rightValue;
+        }
+    }
+
+    /**
+     * Perform a big step change left.
+     */
+    public void bigDecrement() {
+        if (leftValue == rightValue) {
+            return;
+        }
+        value -= bigChange;
+        if (value < leftValue) {
+            value = leftValue;
+        }
+    }
+
+    /**
+     * Perform a big step change right.
+     */
+    public void bigIncrement() {
+        if (rightValue == leftValue) {
+            return;
+        }
+        value += bigChange;
+        if (value > rightValue) {
+            value = rightValue;
+        }
+    }
+
+    /**
+     * Go to the left edge of the scroller.
+     */
+    public void toLeft() {
+        value = leftValue;
+    }
+
+    /**
+     * Go to the right edge of the scroller.
+     */
+    public void toRight() {
+        value = rightValue;
+    }
+
+}
diff --git a/src/jexer/THelpWindow.java b/src/jexer/THelpWindow.java
new file mode 100644 (file)
index 0000000..ee7ce54
--- /dev/null
@@ -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 (file)
index 0000000..2b25484
--- /dev/null
@@ -0,0 +1,5 @@
+windowTitle=Help
+previousButton=Pre&vious
+contentsButton=Co&ntents
+indexButton=\ &Index\ \ 
+closeButton=\ C&lose\ \ 
diff --git a/src/jexer/TImage.java b/src/jexer/TImage.java
new file mode 100644 (file)
index 0000000..b7bfbd0
--- /dev/null
@@ -0,0 +1,831 @@
+/*
+ * 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.awt.image.BufferedImage;
+
+import jexer.bits.Cell;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TImage renders a piece of a bitmap image on screen.
+ */
+public class TImage extends TWidget implements EditMenuUser {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Selections for fitting the image to the text cells.
+     */
+    public enum Scale {
+        /**
+         * No scaling.
+         */
+        NONE,
+
+        /**
+         * Stretch/shrink the image in both directions to fully fill the text
+         * area width/height.
+         */
+        STRETCH,
+
+        /**
+         * Scale the image, preserving aspect ratio, to fill the text area
+         * width/height (like letterbox).  The background color for the
+         * letterboxed area is specified in scaleBackColor.
+         */
+        SCALE,
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Scaling strategy to use.
+     */
+    private Scale scale = Scale.NONE;
+
+    /**
+     * Scaling strategy to use.
+     */
+    private java.awt.Color scaleBackColor = java.awt.Color.BLACK;
+
+    /**
+     * The action to perform when the user clicks on the image.
+     */
+    private TAction clickAction;
+
+    /**
+     * The image to display.
+     */
+    private BufferedImage image;
+
+    /**
+     * The original image from construction time.
+     */
+    private BufferedImage originalImage;
+
+    /**
+     * The current scaling factor for the image.
+     */
+    private double scaleFactor = 1.0;
+
+    /**
+     * The current clockwise rotation for the image.
+     */
+    private int clockwise = 0;
+
+    /**
+     * If true, this widget was resized and a new scaled image must be
+     * produced.
+     */
+    private boolean resized = false;
+
+    /**
+     * Left column of the image.  0 is the left-most column.
+     */
+    private int left;
+
+    /**
+     * Top row of the image.  0 is the top-most row.
+     */
+    private int top;
+
+    /**
+     * The cells containing the broken up image pieces.
+     */
+    private Cell cells[][];
+
+    /**
+     * The number of rows in cells[].
+     */
+    private int cellRows;
+
+    /**
+     * The number of columns in cells[].
+     */
+    private int cellColumns;
+
+    /**
+     * Last text width value.
+     */
+    private int lastTextWidth = -1;
+
+    /**
+     * Last text height value.
+     */
+    private int lastTextHeight = -1;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width number of text cells for width of the image
+     * @param height number of text cells for height of the image
+     * @param image the image to display
+     * @param left left column of the image.  0 is the left-most column.
+     * @param top top row of the image.  0 is the top-most row.
+     */
+    public TImage(final TWidget parent, final int x, final int y,
+        final int width, final int height,
+        final BufferedImage image, final int left, final int top) {
+
+        this(parent, x, y, width, height, image, left, top, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width number of text cells for width of the image
+     * @param height number of text cells for height of the image
+     * @param image the image to display
+     * @param left left column of the image.  0 is the left-most column.
+     * @param top top row of the image.  0 is the top-most row.
+     * @param clickAction function to call when mouse is pressed
+     */
+    public TImage(final TWidget parent, final int x, final int y,
+        final int width, final int height,
+        final BufferedImage image, final int left, final int top,
+        final TAction clickAction) {
+
+        // Set parent and window
+        super(parent, x, y, width, height);
+
+        setCursorVisible(false);
+        this.originalImage = image;
+        this.left = left;
+        this.top = top;
+        this.clickAction = clickAction;
+
+        sizeToImage(true);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (clickAction != null) {
+            clickAction.DO(this);
+            return;
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (!keypress.getKey().isFnKey()) {
+            if (keypress.getKey().getChar() == '+') {
+                // Make the image bigger.
+                scaleFactor *= 1.25;
+                image = null;
+                sizeToImage(true);
+                return;
+            }
+            if (keypress.getKey().getChar() == '-') {
+                // Make the image smaller.
+                scaleFactor *= 0.80;
+                image = null;
+                sizeToImage(true);
+                return;
+            }
+        }
+        if (keypress.equals(kbAltUp)) {
+            // Make the image bigger.
+            scaleFactor *= 1.25;
+            image = null;
+            sizeToImage(true);
+            return;
+        }
+        if (keypress.equals(kbAltDown)) {
+            // Make the image smaller.
+            scaleFactor *= 0.80;
+            image = null;
+            sizeToImage(true);
+            return;
+        }
+        if (keypress.equals(kbAltRight)) {
+            // Rotate clockwise.
+            clockwise++;
+            clockwise %= 4;
+            image = null;
+            sizeToImage(true);
+            return;
+        }
+        if (keypress.equals(kbAltLeft)) {
+            // Rotate counter-clockwise.
+            clockwise--;
+            if (clockwise < 0) {
+                clockwise = 3;
+            }
+            image = null;
+            sizeToImage(true);
+            return;
+        }
+
+        if (keypress.equals(kbShiftLeft)) {
+            switch (scale) {
+            case NONE:
+                setScaleType(Scale.SCALE);
+                return;
+            case STRETCH:
+                setScaleType(Scale.NONE);
+                return;
+            case SCALE:
+                setScaleType(Scale.STRETCH);
+                return;
+            }
+        }
+        if (keypress.equals(kbShiftRight)) {
+            switch (scale) {
+            case NONE:
+                setScaleType(Scale.STRETCH);
+                return;
+            case STRETCH:
+                setScaleType(Scale.SCALE);
+                return;
+            case SCALE:
+                setScaleType(Scale.NONE);
+                return;
+            }
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    /**
+     * Handle resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        // Get my width/height set correctly.
+        super.onResize(event);
+
+        if (scale == Scale.NONE) {
+            return;
+        }
+        image = null;
+        resized = true;
+    }
+
+    /**
+     * Handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (command.equals(cmCopy)) {
+            // Copy image to clipboard.
+            getClipboard().copyImage(image);
+            return;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the image.
+     */
+    @Override
+    public void draw() {
+        sizeToImage(false);
+
+        // We have already broken the image up, just draw the last set of
+        // cells.
+        for (int x = 0; (x < getWidth()) && (x + left < cellColumns); x++) {
+            if ((left + x) * lastTextWidth > image.getWidth()) {
+                continue;
+            }
+
+            for (int y = 0; (y < getHeight()) && (y + top < cellRows); y++) {
+                if ((top + y) * lastTextHeight > image.getHeight()) {
+                    continue;
+                }
+                assert (x + left < cellColumns);
+                assert (y + top < cellRows);
+
+                getWindow().putCharXY(x, y, cells[x + left][y + top]);
+            }
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TImage -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Size cells[][] according to the screen font size.
+     *
+     * @param always if true, always resize the cells
+     */
+    private void sizeToImage(final boolean always) {
+        int textWidth = getScreen().getTextWidth();
+        int textHeight = getScreen().getTextHeight();
+
+        if (image == null) {
+            image = rotateImage(originalImage, clockwise);
+            image = scaleImage(image, scaleFactor, getWidth(), getHeight(),
+                textWidth, textHeight);
+        }
+
+        if ((always == true) ||
+            (resized == true) ||
+            ((textWidth > 0)
+                && (textWidth != lastTextWidth)
+                && (textHeight > 0)
+                && (textHeight != lastTextHeight))
+        ) {
+            resized = false;
+
+            cellColumns = image.getWidth() / textWidth;
+            if (cellColumns * textWidth < image.getWidth()) {
+                cellColumns++;
+            }
+            cellRows = image.getHeight() / textHeight;
+            if (cellRows * textHeight < image.getHeight()) {
+                cellRows++;
+            }
+
+            // Break the image up into an array of cells.
+            cells = new Cell[cellColumns][cellRows];
+
+            for (int x = 0; x < cellColumns; x++) {
+                for (int y = 0; y < cellRows; y++) {
+
+                    int width = textWidth;
+                    if ((x + 1) * textWidth > image.getWidth()) {
+                        width = image.getWidth() - (x * textWidth);
+                    }
+                    int height = textHeight;
+                    if ((y + 1) * textHeight > image.getHeight()) {
+                        height = image.getHeight() - (y * textHeight);
+                    }
+
+                    Cell cell = new Cell();
+                    if ((width != textWidth) || (height != textHeight)) {
+                        BufferedImage newImage;
+                        newImage = new BufferedImage(textWidth, textHeight,
+                            BufferedImage.TYPE_INT_ARGB);
+
+                        java.awt.Graphics gr = newImage.getGraphics();
+                        gr.drawImage(image.getSubimage(x * textWidth,
+                                y * textHeight, width, height),
+                            0, 0, null, null);
+                        gr.dispose();
+                        cell.setImage(newImage);
+                    } else {
+                        cell.setImage(image.getSubimage(x * textWidth,
+                                y * textHeight, width, height));
+                    }
+
+                    cells[x][y] = cell;
+                }
+            }
+
+            lastTextWidth = textWidth;
+            lastTextHeight = textHeight;
+        }
+
+        if ((left + getWidth()) > cellColumns) {
+            left = cellColumns - getWidth();
+        }
+        if (left < 0) {
+            left = 0;
+        }
+        if ((top + getHeight()) > cellRows) {
+            top = cellRows - getHeight();
+        }
+        if (top < 0) {
+            top = 0;
+        }
+    }
+
+    /**
+     * Get the top corner to render.
+     *
+     * @return the top row
+     */
+    public int getTop() {
+        return top;
+    }
+
+    /**
+     * Set the top corner to render.
+     *
+     * @param top the new top row
+     */
+    public void setTop(final int top) {
+        this.top = top;
+        if (this.top > cellRows - getHeight()) {
+            this.top = cellRows - getHeight();
+        }
+        if (this.top < 0) {
+            this.top = 0;
+        }
+    }
+
+    /**
+     * Get the left corner to render.
+     *
+     * @return the left column
+     */
+    public int getLeft() {
+        return left;
+    }
+
+    /**
+     * Set the left corner to render.
+     *
+     * @param left the new left column
+     */
+    public void setLeft(final int left) {
+        this.left = left;
+        if (this.left > cellColumns - getWidth()) {
+            this.left = cellColumns - getWidth();
+        }
+        if (this.left < 0) {
+            this.left = 0;
+        }
+    }
+
+    /**
+     * Get the number of text cell rows for this image.
+     *
+     * @return the number of rows
+     */
+    public int getRows() {
+        return cellRows;
+    }
+
+    /**
+     * Get the number of text cell columns for this image.
+     *
+     * @return the number of columns
+     */
+    public int getColumns() {
+        return cellColumns;
+    }
+
+    /**
+     * Get the raw (unprocessed) image.
+     *
+     * @return the image
+     */
+    public BufferedImage getImage() {
+        return originalImage;
+    }
+
+    /**
+     * Set the raw image, and reprocess to make the visible image.
+     *
+     * @param image the new image
+     */
+    public void setImage(final BufferedImage image) {
+        this.originalImage = image;
+        this.image = null;
+        sizeToImage(true);
+    }
+
+    /**
+     * Get the visible (processed) image.
+     *
+     * @return the image that is currently on screen
+     */
+    public BufferedImage getVisibleImage() {
+        return image;
+    }
+
+    /**
+     * Get the scaling strategy.
+     *
+     * @return Scale.NONE, Scale.STRETCH, etc.
+     */
+    public Scale getScaleType() {
+        return scale;
+    }
+
+    /**
+     * Set the scaling strategy.
+     *
+     * @param scale Scale.NONE, Scale.STRETCH, etc.
+     */
+    public void setScaleType(final Scale scale) {
+        this.scale = scale;
+        this.image = null;
+        sizeToImage(true);
+    }
+
+    /**
+     * Get the scale factor.
+     *
+     * @return the scale factor
+     */
+    public double getScaleFactor() {
+        return scaleFactor;
+    }
+
+    /**
+     * Set the scale factor.  1.0 means no scaling.
+     *
+     * @param scaleFactor the new scale factor
+     */
+    public void setScaleFactor(final double scaleFactor) {
+        this.scaleFactor = scaleFactor;
+        image = null;
+        sizeToImage(true);
+    }
+
+    /**
+     * Get the rotation, as degrees.
+     *
+     * @return the rotation in degrees
+     */
+    public int getRotation() {
+        switch (clockwise) {
+        case 0:
+            return 0;
+        case 1:
+            return 90;
+        case 2:
+            return 180;
+        case 3:
+            return 270;
+        default:
+            // Don't know how this happened, but fix it.
+            clockwise = 0;
+            image = null;
+            sizeToImage(true);
+            return 0;
+        }
+    }
+
+    /**
+     * Set the rotation, as degrees clockwise.
+     *
+     * @param rotation 0, 90, 180, or 270
+     */
+    public void setRotation(final int rotation) {
+        switch (rotation) {
+        case 0:
+            clockwise = 0;
+            break;
+        case 90:
+            clockwise = 1;
+            break;
+        case 180:
+            clockwise = 2;
+            break;
+        case 270:
+            clockwise = 3;
+            break;
+        default:
+            // Don't know how this happened, but fix it.
+            clockwise = 0;
+            break;
+        }
+
+        image = null;
+        sizeToImage(true);
+    }
+
+    /**
+     * Scale an image by to be scaleFactor size.
+     *
+     * @param image the image to scale
+     * @param factor the scale to make the new image
+     * @param width the number of text cell columns for the destination image
+     * @param height the number of text cell rows for the destination image
+     * @param textWidth the width in pixels for one text cell
+     * @param textHeight the height in pixels for one text cell
+     */
+    private BufferedImage scaleImage(final BufferedImage image,
+        final double factor, final int width, final int height,
+        final int textWidth, final int textHeight) {
+
+        if ((scale == Scale.NONE) && (Math.abs(factor - 1.0) < 0.03)) {
+            // If we are within 3% of 1.0, just return the original image.
+            return image;
+        }
+
+        int destWidth = 0;
+        int destHeight = 0;
+        int x = 0;
+        int y = 0;
+
+        BufferedImage newImage = null;
+
+        switch (scale) {
+        case NONE:
+            destWidth = (int) (image.getWidth() * factor);
+            destHeight = (int) (image.getHeight() * factor);
+            newImage = new BufferedImage(destWidth, destHeight,
+                BufferedImage.TYPE_INT_ARGB);
+            break;
+        case STRETCH:
+            destWidth = width * textWidth;
+            destHeight = height * textHeight;
+            newImage = new BufferedImage(destWidth, destHeight,
+                BufferedImage.TYPE_INT_ARGB);
+            break;
+        case SCALE:
+            double a = (double) image.getWidth() / image.getHeight();
+            double b = (double) (width * textWidth) / (height * textHeight);
+            assert (a > 0);
+            assert (b > 0);
+
+            /*
+            System.err.println("Scale: original " + image.getWidth() +
+                "x" + image.getHeight());
+            System.err.println("         screen " + (width * textWidth) +
+                "x" + (height * textHeight));
+            System.err.println("A " + a + " B " + b);
+             */
+
+            if (a > b) {
+                // Horizontal letterbox
+                destWidth = width * textWidth;
+                destHeight = (int) (destWidth / a);
+                y = ((height * textHeight) - destHeight) / 2;
+                assert (y >= 0);
+                /*
+                System.err.println("Horizontal letterbox: " + destWidth +
+                    "x" + destHeight + ", Y offset " + y);
+                 */
+            } else {
+                // Vertical letterbox
+                destHeight = height * textHeight;
+                destWidth = (int) (destHeight * a);
+                x = ((width * textWidth) - destWidth) / 2;
+                assert (x >= 0);
+                /*
+                System.err.println("Vertical letterbox: " + destWidth +
+                    "x" + destHeight + ", X offset " + x);
+                 */
+            }
+            newImage = new BufferedImage(width * textWidth, height * textHeight,
+                BufferedImage.TYPE_INT_ARGB);
+            break;
+        }
+
+        java.awt.Graphics gr = newImage.createGraphics();
+        if (scale == Scale.SCALE) {
+            gr.setColor(scaleBackColor);
+            gr.fillRect(0, 0, width * textWidth, height * textHeight);
+        }
+        gr.drawImage(image, x, y, destWidth, destHeight, null);
+        gr.dispose();
+        return newImage;
+    }
+
+    /**
+     * Rotate an image either clockwise or counterclockwise.
+     *
+     * @param image the image to scale
+     * @param clockwise number of turns clockwise
+     */
+    private BufferedImage rotateImage(final BufferedImage image,
+        final int clockwise) {
+
+        if (clockwise % 4 == 0) {
+            return image;
+        }
+
+        BufferedImage newImage = null;
+
+        if (clockwise % 4 == 1) {
+            // 90 degrees clockwise
+            newImage = new BufferedImage(image.getHeight(), image.getWidth(),
+                BufferedImage.TYPE_INT_ARGB);
+            for (int x = 0; x < image.getWidth(); x++) {
+                for (int y = 0; y < image.getHeight(); y++) {
+                    newImage.setRGB(y, x,
+                        image.getRGB(x, image.getHeight() - 1 - y));
+                }
+            }
+        } else if (clockwise % 4 == 2) {
+            // 180 degrees clockwise
+            newImage = new BufferedImage(image.getWidth(), image.getHeight(),
+                BufferedImage.TYPE_INT_ARGB);
+            for (int x = 0; x < image.getWidth(); x++) {
+                for (int y = 0; y < image.getHeight(); y++) {
+                    newImage.setRGB(x, y,
+                        image.getRGB(image.getWidth() - 1 - x,
+                            image.getHeight() - 1 - y));
+                }
+            }
+        } else if (clockwise % 4 == 3) {
+            // 270 degrees clockwise
+            newImage = new BufferedImage(image.getHeight(), image.getWidth(),
+                BufferedImage.TYPE_INT_ARGB);
+            for (int x = 0; x < image.getWidth(); x++) {
+                for (int y = 0; y < image.getHeight(); y++) {
+                    newImage.setRGB(y, x,
+                        image.getRGB(image.getWidth() - 1 - x, y));
+                }
+            }
+        }
+
+        return newImage;
+    }
+
+    // ------------------------------------------------------------------------
+    // EditMenuUser -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if the cut menu item should be enabled.
+     *
+     * @return true if the cut menu item should be enabled
+     */
+    public boolean isEditMenuCut() {
+        return false;
+    }
+
+    /**
+     * Check if the copy menu item should be enabled.
+     *
+     * @return true if the copy menu item should be enabled
+     */
+    public boolean isEditMenuCopy() {
+        return true;
+    }
+
+    /**
+     * Check if the paste menu item should be enabled.
+     *
+     * @return true if the paste menu item should be enabled
+     */
+    public boolean isEditMenuPaste() {
+        return false;
+    }
+
+    /**
+     * Check if the clear menu item should be enabled.
+     *
+     * @return true if the clear menu item should be enabled
+     */
+    public boolean isEditMenuClear() {
+        return false;
+    }
+
+}
diff --git a/src/jexer/TImageWindow.java b/src/jexer/TImageWindow.java
new file mode 100644 (file)
index 0000000..15db1da
--- /dev/null
@@ -0,0 +1,291 @@
+/*
+ * 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.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.ResourceBundle;
+import javax.imageio.ImageIO;
+
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TImageWindow shows an image with scrollbars.
+ */
+public class TImageWindow extends TScrollableWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TImageWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The number of lines to scroll on mouse wheel up/down.
+     */
+    private static final int wheelScrollSize = 3;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto the TImage so I can resize it with the window.
+     */
+    private TImage imageField;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor opens a file.
+     *
+     * @param parent the main application
+     * @param file the file to open
+     * @throws IOException if a java.io operation throws
+     */
+    public TImageWindow(final TApplication parent,
+        final File file) throws IOException {
+
+        this(parent, file, 0, 0, parent.getScreen().getWidth(),
+            parent.getDesktopBottom() - parent.getDesktopTop());
+    }
+
+    /**
+     * Public constructor opens a file.
+     *
+     * @param parent the main application
+     * @param file the file to open
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     * @throws IOException if a java.io operation throws
+     */
+    public TImageWindow(final TApplication parent, final File file,
+        final int x, final int y, final int width,
+        final int height) throws IOException {
+
+        super(parent, file.getName(), x, y, width, height, RESIZABLE);
+
+        BufferedImage image = ImageIO.read(file);
+
+        imageField = addImage(0, 0, getWidth() - 2, getHeight() - 2,
+            image, 0, 0);
+        setTitle(file.getName());
+
+        setupAfterImage();
+    }
+
+    /**
+     * Setup other fields after the image is created.
+     */
+    private void setupAfterImage() {
+        if (imageField.getRows() < getHeight() - 2) {
+            imageField.setHeight(imageField.getRows());
+            setHeight(imageField.getRows() + 2);
+        }
+        if (imageField.getColumns() < getWidth() - 2) {
+            imageField.setWidth(imageField.getColumns());
+            setWidth(imageField.getColumns() + 2);
+        }
+
+        hScroller = new THScroller(this,
+            Math.min(Math.max(0, getWidth() - 17), 17),
+            getHeight() - 2,
+            getWidth() - Math.min(Math.max(0, getWidth() - 17), 17) - 3);
+        vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+        setTopValue(0);
+        setBottomValue(imageField.getRows() - imageField.getHeight());
+        setLeftValue(0);
+        setRightValue(imageField.getColumns() - imageField.getWidth());
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseDown(mouse);
+
+        if (mouse.isMouseWheelUp()) {
+            imageField.setTop(imageField.getTop() - wheelScrollSize);
+        } else if (mouse.isMouseWheelDown()) {
+            imageField.setTop(imageField.getTop() + wheelScrollSize);
+        }
+        setVerticalValue(imageField.getTop());
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseUp(mouse);
+
+        if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+            // Clicked/dragged on vertical scrollbar
+            imageField.setTop(getVerticalValue());
+        }
+        if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+            // Clicked/dragged on horizontal scrollbar
+            imageField.setLeft(getHorizontalValue());
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseMotion(mouse);
+
+        if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+            // Clicked/dragged on vertical scrollbar
+            imageField.setTop(getVerticalValue());
+        }
+        if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+            // Clicked/dragged on horizontal scrollbar
+            imageField.setLeft(getHorizontalValue());
+        }
+    }
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the image field
+            TResizeEvent imageSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                event.getWidth() - 2, event.getHeight() - 2);
+            imageField.onResize(imageSize);
+
+            // Have TScrollableWindow handle the scrollbars
+            super.onResize(event);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(event);
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbUp)) {
+            verticalDecrement();
+            imageField.setTop(getVerticalValue());
+            return;
+        }
+        if (keypress.equals(kbDown)) {
+            verticalIncrement();
+            imageField.setTop(getVerticalValue());
+            return;
+        }
+        if (keypress.equals(kbPgUp)) {
+            bigVerticalDecrement();
+            imageField.setTop(getVerticalValue());
+            return;
+        }
+        if (keypress.equals(kbPgDn)) {
+            bigVerticalIncrement();
+            imageField.setTop(getVerticalValue());
+            return;
+        }
+        if (keypress.equals(kbRight)) {
+            horizontalIncrement();
+            imageField.setLeft(getHorizontalValue());
+            return;
+        }
+        if (keypress.equals(kbLeft)) {
+            horizontalDecrement();
+            imageField.setLeft(getHorizontalValue());
+            return;
+        }
+
+        // We did not take it, let the TImage instance see it.
+        super.onKeypress(keypress);
+
+        setVerticalValue(imageField.getTop());
+        setBottomValue(imageField.getRows() - imageField.getHeight());
+        setHorizontalValue(imageField.getLeft());
+        setRightValue(imageField.getColumns() - imageField.getWidth());
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the window.
+     */
+    @Override
+    public void draw() {
+        // Draw as normal.
+        super.draw();
+
+        // We have to get the scrollbar values after we have let the image
+        // try to draw.
+        setBottomValue(imageField.getRows() - imageField.getHeight());
+        setRightValue(imageField.getColumns() - imageField.getWidth());
+    }
+
+}
diff --git a/src/jexer/TImageWindow.properties b/src/jexer/TImageWindow.properties
new file mode 100644 (file)
index 0000000..a26fce5
--- /dev/null
@@ -0,0 +1 @@
+statusBar=Alt-\u2190\u2192-Rotate Left/Right  Alt-\u2191\u2193-Bigger/Smaller  \u2190\u2192\u2191\u2193-Pan  Shift-\u2190\u2192-Scale
diff --git a/src/jexer/TInputBox.java b/src/jexer/TInputBox.java
new file mode 100644 (file)
index 0000000..d60d0b5
--- /dev/null
@@ -0,0 +1,138 @@
+/*
+ * 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;
+
+/**
+ * TInputBox is a system-modal dialog with an OK button and a text input
+ * field.  Call it like:
+ *
+ * <pre>
+ * {@code
+ *     box = inputBox(title, caption);
+ *     if (box.getText().equals("yes")) {
+ *         ... the user entered "yes", do stuff ...
+ *     }
+ * }
+ * </pre>
+ *
+ */
+public class TInputBox extends TMessageBox {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The input field.
+     */
+    private TField field;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  The input box will be centered on screen.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     */
+    public TInputBox(final TApplication application, final String title,
+        final String caption) {
+
+        this(application, title, caption, "", Type.OK);
+    }
+
+    /**
+     * Public constructor.  The input box will be centered on screen.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param text initial text to seed the field with
+     */
+    public TInputBox(final TApplication application, final String title,
+        final String caption, final String text) {
+
+        this(application, title, caption, text, Type.OK);
+    }
+
+    /**
+     * Public constructor.  The input box will be centered on screen.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param text initial text to seed the field with
+     * @param type one of the Type constants.  Default is Type.OK.
+     */
+    public TInputBox(final TApplication application, final String title,
+        final String caption, final String text, final Type type) {
+
+        super(application, title, caption, type, false);
+
+        for (TWidget widget: getChildren()) {
+            if (widget instanceof TButton) {
+                widget.setY(widget.getY() + 2);
+            }
+        }
+
+        setHeight(getHeight() + 2);
+        field = addField(1, getHeight() - 6, getWidth() - 4, false, text);
+
+        // Set the secondaryThread to run me
+        getApplication().enableSecondaryEventReceiver(this);
+
+        // Yield to the secondary thread.  When I come back from the
+        // constructor response will already be set.
+        getApplication().yield();
+    }
+
+    // ------------------------------------------------------------------------
+    // TMessageBox ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // TInputBox --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Retrieve the answer text.
+     *
+     * @return the answer text
+     */
+    public String getText() {
+        return field.getText();
+    }
+
+}
diff --git a/src/jexer/TKeypress.java b/src/jexer/TKeypress.java
new file mode 100644 (file)
index 0000000..20db8bb
--- /dev/null
@@ -0,0 +1,1068 @@
+/*
+ * 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;
+
+/**
+ * This class represents keystrokes.
+ */
+public class TKeypress {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // Various special keystrokes
+
+    /**
+     * "No key".
+     */
+    public static final int NONE        = 255;
+
+    /**
+     * Function key F1.
+     */
+    public static final int F1          = 1;
+
+    /**
+     * Function key F2.
+     */
+    public static final int F2          = 2;
+
+    /**
+     * Function key F3.
+     */
+    public static final int F3          = 3;
+
+    /**
+     * Function key F4.
+     */
+    public static final int F4          = 4;
+
+    /**
+     * Function key F5.
+     */
+    public static final int F5          = 5;
+
+    /**
+     * Function key F6.
+     */
+    public static final int F6          = 6;
+
+    /**
+     * Function key F7.
+     */
+    public static final int F7          = 7;
+
+    /**
+     * Function key F8.
+     */
+    public static final int F8          = 8;
+
+    /**
+     * Function key F9.
+     */
+    public static final int F9          = 9;
+
+    /**
+     * Function key F10.
+     */
+    public static final int F10         = 10;
+
+    /**
+     * Function key F11.
+     */
+    public static final int F11         = 11;
+
+    /**
+     * Function key F12.
+     */
+    public static final int F12         = 12;
+
+    /**
+     * Home.
+     */
+    public static final int HOME        = 20;
+
+    /**
+     * End.
+     */
+    public static final int END         = 21;
+
+    /**
+     * Page up.
+     */
+    public static final int PGUP        = 22;
+
+    /**
+     * Page down.
+     */
+    public static final int PGDN        = 23;
+
+    /**
+     * Insert.
+     */
+    public static final int INS         = 24;
+
+    /**
+     * Delete.
+     */
+    public static final int DEL         = 25;
+
+    /**
+     * Right arrow.
+     */
+    public static final int RIGHT       = 30;
+
+    /**
+     * Left arrow.
+     */
+    public static final int LEFT        = 31;
+
+    /**
+     * Up arrow.
+     */
+    public static final int UP          = 32;
+
+    /**
+     * Down arrow.
+     */
+    public static final int DOWN        = 33;
+
+    /**
+     * Tab.
+     */
+    public static final int TAB         = 40;
+
+    /**
+     * Back-tab (shift-tab).
+     */
+    public static final int BTAB        = 41;
+
+    /**
+     * Enter.
+     */
+    public static final int ENTER       = 42;
+
+    /**
+     * Escape.
+     */
+    public static final int ESC         = 43;
+
+    // Special "no-key" keypress, used to ignore undefined keystrokes
+    public static final TKeypress kbNoKey = new TKeypress(true,
+            TKeypress.NONE, ' ', false, false, false);
+
+    // Normal keys
+    public static final TKeypress kbF1 = new TKeypress(true,
+            TKeypress.F1, ' ', false, false, false);
+    public static final TKeypress kbF2 = new TKeypress(true,
+            TKeypress.F2, ' ', false, false, false);
+    public static final TKeypress kbF3 = new TKeypress(true,
+            TKeypress.F3, ' ', false, false, false);
+    public static final TKeypress kbF4 = new TKeypress(true,
+            TKeypress.F4, ' ', false, false, false);
+    public static final TKeypress kbF5 = new TKeypress(true,
+            TKeypress.F5, ' ', false, false, false);
+    public static final TKeypress kbF6 = new TKeypress(true,
+            TKeypress.F6, ' ', false, false, false);
+    public static final TKeypress kbF7 = new TKeypress(true,
+            TKeypress.F7, ' ', false, false, false);
+    public static final TKeypress kbF8 = new TKeypress(true,
+            TKeypress.F8, ' ', false, false, false);
+    public static final TKeypress kbF9 = new TKeypress(true,
+            TKeypress.F9, ' ', false, false, false);
+    public static final TKeypress kbF10 = new TKeypress(true,
+            TKeypress.F10, ' ', false, false, false);
+    public static final TKeypress kbF11 = new TKeypress(true,
+            TKeypress.F11, ' ', false, false, false);
+    public static final TKeypress kbF12 = new TKeypress(true,
+            TKeypress.F12, ' ', false, false, false);
+    public static final TKeypress kbAltF1 = new TKeypress(true,
+            TKeypress.F1, ' ', true, false, false);
+    public static final TKeypress kbAltF2 = new TKeypress(true,
+            TKeypress.F2, ' ', true, false, false);
+    public static final TKeypress kbAltF3 = new TKeypress(true,
+            TKeypress.F3, ' ', true, false, false);
+    public static final TKeypress kbAltF4 = new TKeypress(true,
+            TKeypress.F4, ' ', true, false, false);
+    public static final TKeypress kbAltF5 = new TKeypress(true,
+            TKeypress.F5, ' ', true, false, false);
+    public static final TKeypress kbAltF6 = new TKeypress(true,
+            TKeypress.F6, ' ', true, false, false);
+    public static final TKeypress kbAltF7 = new TKeypress(true,
+            TKeypress.F7, ' ', true, false, false);
+    public static final TKeypress kbAltF8 = new TKeypress(true,
+            TKeypress.F8, ' ', true, false, false);
+    public static final TKeypress kbAltF9 = new TKeypress(true,
+            TKeypress.F9, ' ', true, false, false);
+    public static final TKeypress kbAltF10 = new TKeypress(true,
+            TKeypress.F10, ' ', true, false, false);
+    public static final TKeypress kbAltF11 = new TKeypress(true,
+            TKeypress.F11, ' ', true, false, false);
+    public static final TKeypress kbAltF12 = new TKeypress(true,
+            TKeypress.F12, ' ', true, false, false);
+    public static final TKeypress kbCtrlF1 = new TKeypress(true,
+            TKeypress.F1, ' ', false, true, false);
+    public static final TKeypress kbCtrlF2 = new TKeypress(true,
+            TKeypress.F2, ' ', false, true, false);
+    public static final TKeypress kbCtrlF3 = new TKeypress(true,
+            TKeypress.F3, ' ', false, true, false);
+    public static final TKeypress kbCtrlF4 = new TKeypress(true,
+            TKeypress.F4, ' ', false, true, false);
+    public static final TKeypress kbCtrlF5 = new TKeypress(true,
+            TKeypress.F5, ' ', false, true, false);
+    public static final TKeypress kbCtrlF6 = new TKeypress(true,
+            TKeypress.F6, ' ', false, true, false);
+    public static final TKeypress kbCtrlF7 = new TKeypress(true,
+            TKeypress.F7, ' ', false, true, false);
+    public static final TKeypress kbCtrlF8 = new TKeypress(true,
+            TKeypress.F8, ' ', false, true, false);
+    public static final TKeypress kbCtrlF9 = new TKeypress(true,
+            TKeypress.F9, ' ', false, true, false);
+    public static final TKeypress kbCtrlF10 = new TKeypress(true,
+            TKeypress.F10, ' ', false, true, false);
+    public static final TKeypress kbCtrlF11 = new TKeypress(true,
+            TKeypress.F11, ' ', false, true, false);
+    public static final TKeypress kbCtrlF12 = new TKeypress(true,
+            TKeypress.F12, ' ', false, true, false);
+    public static final TKeypress kbShiftF1 = new TKeypress(true,
+            TKeypress.F1, ' ', false, false, true);
+    public static final TKeypress kbShiftF2 = new TKeypress(true,
+            TKeypress.F2, ' ', false, false, true);
+    public static final TKeypress kbShiftF3 = new TKeypress(true,
+            TKeypress.F3, ' ', false, false, true);
+    public static final TKeypress kbShiftF4 = new TKeypress(true,
+            TKeypress.F4, ' ', false, false, true);
+    public static final TKeypress kbShiftF5 = new TKeypress(true,
+            TKeypress.F5, ' ', false, false, true);
+    public static final TKeypress kbShiftF6 = new TKeypress(true,
+            TKeypress.F6, ' ', false, false, true);
+    public static final TKeypress kbShiftF7 = new TKeypress(true,
+            TKeypress.F7, ' ', false, false, true);
+    public static final TKeypress kbShiftF8 = new TKeypress(true,
+            TKeypress.F8, ' ', false, false, true);
+    public static final TKeypress kbShiftF9 = new TKeypress(true,
+            TKeypress.F9, ' ', false, false, true);
+    public static final TKeypress kbShiftF10 = new TKeypress(true,
+            TKeypress.F10, ' ', false, false, true);
+    public static final TKeypress kbShiftF11 = new TKeypress(true,
+            TKeypress.F11, ' ', false, false, true);
+    public static final TKeypress kbShiftF12 = new TKeypress(true,
+            TKeypress.F12, ' ', false, false, true);
+    public static final TKeypress kbEnter = new TKeypress(true,
+            TKeypress.ENTER, ' ', false, false, false);
+    public static final TKeypress kbTab = new TKeypress(true,
+            TKeypress.TAB, ' ', false, false, false);
+    public static final TKeypress kbEsc = new TKeypress(true,
+            TKeypress.ESC, ' ', false, false, false);
+    public static final TKeypress kbHome = new TKeypress(true,
+            TKeypress.HOME, ' ', false, false, false);
+    public static final TKeypress kbEnd = new TKeypress(true,
+            TKeypress.END, ' ', false, false, false);
+    public static final TKeypress kbPgUp = new TKeypress(true,
+            TKeypress.PGUP, ' ', false, false, false);
+    public static final TKeypress kbPgDn = new TKeypress(true,
+            TKeypress.PGDN, ' ', false, false, false);
+    public static final TKeypress kbIns = new TKeypress(true,
+            TKeypress.INS, ' ', false, false, false);
+    public static final TKeypress kbDel = new TKeypress(true,
+            TKeypress.DEL, ' ', false, false, false);
+    public static final TKeypress kbUp = new TKeypress(true,
+            TKeypress.UP, ' ', false, false, false);
+    public static final TKeypress kbDown = new TKeypress(true,
+            TKeypress.DOWN, ' ', false, false, false);
+    public static final TKeypress kbLeft = new TKeypress(true,
+            TKeypress.LEFT, ' ', false, false, false);
+    public static final TKeypress kbRight = new TKeypress(true,
+            TKeypress.RIGHT, ' ', false, false, false);
+    public static final TKeypress kbAltEnter = new TKeypress(true,
+            TKeypress.ENTER, ' ', true, false, false);
+    public static final TKeypress kbAltTab = new TKeypress(true,
+            TKeypress.TAB, ' ', true, false, false);
+    public static final TKeypress kbAltEsc = new TKeypress(true,
+            TKeypress.ESC, ' ', true, false, false);
+    public static final TKeypress kbAltHome = new TKeypress(true,
+            TKeypress.HOME, ' ', true, false, false);
+    public static final TKeypress kbAltEnd = new TKeypress(true,
+            TKeypress.END, ' ', true, false, false);
+    public static final TKeypress kbAltPgUp = new TKeypress(true,
+            TKeypress.PGUP, ' ', true, false, false);
+    public static final TKeypress kbAltPgDn = new TKeypress(true,
+            TKeypress.PGDN, ' ', true, false, false);
+    public static final TKeypress kbAltIns = new TKeypress(true,
+            TKeypress.INS, ' ', true, false, false);
+    public static final TKeypress kbAltDel = new TKeypress(true,
+            TKeypress.DEL, ' ', true, false, false);
+    public static final TKeypress kbAltUp = new TKeypress(true,
+            TKeypress.UP, ' ', true, false, false);
+    public static final TKeypress kbAltDown = new TKeypress(true,
+            TKeypress.DOWN, ' ', true, false, false);
+    public static final TKeypress kbAltLeft = new TKeypress(true,
+            TKeypress.LEFT, ' ', true, false, false);
+    public static final TKeypress kbAltRight = new TKeypress(true,
+            TKeypress.RIGHT, ' ', true, false, false);
+    public static final TKeypress kbCtrlEnter = new TKeypress(true,
+            TKeypress.ENTER, ' ', false, true, false);
+    public static final TKeypress kbCtrlTab = new TKeypress(true,
+            TKeypress.TAB, ' ', false, true, false);
+    public static final TKeypress kbCtrlEsc = new TKeypress(true,
+            TKeypress.ESC, ' ', false, true, false);
+    public static final TKeypress kbCtrlHome = new TKeypress(true,
+            TKeypress.HOME, ' ', false, true, false);
+    public static final TKeypress kbCtrlEnd = new TKeypress(true,
+            TKeypress.END, ' ', false, true, false);
+    public static final TKeypress kbCtrlPgUp = new TKeypress(true,
+            TKeypress.PGUP, ' ', false, true, false);
+    public static final TKeypress kbCtrlPgDn = new TKeypress(true,
+            TKeypress.PGDN, ' ', false, true, false);
+    public static final TKeypress kbCtrlIns = new TKeypress(true,
+            TKeypress.INS, ' ', false, true, false);
+    public static final TKeypress kbCtrlDel = new TKeypress(true,
+            TKeypress.DEL, ' ', false, true, false);
+    public static final TKeypress kbCtrlUp = new TKeypress(true,
+            TKeypress.UP, ' ', false, true, false);
+    public static final TKeypress kbCtrlDown = new TKeypress(true,
+            TKeypress.DOWN, ' ', false, true, false);
+    public static final TKeypress kbCtrlLeft = new TKeypress(true,
+            TKeypress.LEFT, ' ', false, true, false);
+    public static final TKeypress kbCtrlRight = new TKeypress(true,
+            TKeypress.RIGHT, ' ', false, true, false);
+    public static final TKeypress kbShiftEnter = new TKeypress(true,
+            TKeypress.ENTER, ' ', false, false, true);
+    public static final TKeypress kbShiftTab = new TKeypress(true,
+            TKeypress.TAB, ' ', false, false, true);
+    public static final TKeypress kbBackTab = new TKeypress(true,
+            TKeypress.BTAB, ' ', false, false, false);
+    public static final TKeypress kbShiftEsc = new TKeypress(true,
+            TKeypress.ESC, ' ', false, false, true);
+    public static final TKeypress kbShiftHome = new TKeypress(true,
+            TKeypress.HOME, ' ', false, false, true);
+    public static final TKeypress kbShiftEnd = new TKeypress(true,
+            TKeypress.END, ' ', false, false, true);
+    public static final TKeypress kbShiftPgUp = new TKeypress(true,
+            TKeypress.PGUP, ' ', false, false, true);
+    public static final TKeypress kbShiftPgDn = new TKeypress(true,
+            TKeypress.PGDN, ' ', false, false, true);
+    public static final TKeypress kbShiftIns = new TKeypress(true,
+            TKeypress.INS, ' ', false, false, true);
+    public static final TKeypress kbShiftDel = new TKeypress(true,
+            TKeypress.DEL, ' ', false, false, true);
+    public static final TKeypress kbShiftUp = new TKeypress(true,
+            TKeypress.UP, ' ', false, false, true);
+    public static final TKeypress kbShiftDown = new TKeypress(true,
+            TKeypress.DOWN, ' ', false, false, true);
+    public static final TKeypress kbShiftLeft = new TKeypress(true,
+            TKeypress.LEFT, ' ', false, false, true);
+    public static final TKeypress kbShiftRight = new TKeypress(true,
+            TKeypress.RIGHT, ' ', false, false, true);
+    public static final TKeypress kbA = new TKeypress(false,
+            0, 'a', false, false, false);
+    public static final TKeypress kbB = new TKeypress(false,
+            0, 'b', false, false, false);
+    public static final TKeypress kbC = new TKeypress(false,
+            0, 'c', false, false, false);
+    public static final TKeypress kbD = new TKeypress(false,
+            0, 'd', false, false, false);
+    public static final TKeypress kbE = new TKeypress(false,
+            0, 'e', false, false, false);
+    public static final TKeypress kbF = new TKeypress(false,
+            0, 'f', false, false, false);
+    public static final TKeypress kbG = new TKeypress(false,
+            0, 'g', false, false, false);
+    public static final TKeypress kbH = new TKeypress(false,
+            0, 'h', false, false, false);
+    public static final TKeypress kbI = new TKeypress(false,
+            0, 'i', false, false, false);
+    public static final TKeypress kbJ = new TKeypress(false,
+            0, 'j', false, false, false);
+    public static final TKeypress kbK = new TKeypress(false,
+            0, 'k', false, false, false);
+    public static final TKeypress kbL = new TKeypress(false,
+            0, 'l', false, false, false);
+    public static final TKeypress kbM = new TKeypress(false,
+            0, 'm', false, false, false);
+    public static final TKeypress kbN = new TKeypress(false,
+            0, 'n', false, false, false);
+    public static final TKeypress kbO = new TKeypress(false,
+            0, 'o', false, false, false);
+    public static final TKeypress kbP = new TKeypress(false,
+            0, 'p', false, false, false);
+    public static final TKeypress kbQ = new TKeypress(false,
+            0, 'q', false, false, false);
+    public static final TKeypress kbR = new TKeypress(false,
+            0, 'r', false, false, false);
+    public static final TKeypress kbS = new TKeypress(false,
+            0, 's', false, false, false);
+    public static final TKeypress kbT = new TKeypress(false,
+            0, 't', false, false, false);
+    public static final TKeypress kbU = new TKeypress(false,
+            0, 'u', false, false, false);
+    public static final TKeypress kbV = new TKeypress(false,
+            0, 'v', false, false, false);
+    public static final TKeypress kbW = new TKeypress(false,
+            0, 'w', false, false, false);
+    public static final TKeypress kbX = new TKeypress(false,
+            0, 'x', false, false, false);
+    public static final TKeypress kbY = new TKeypress(false,
+            0, 'y', false, false, false);
+    public static final TKeypress kbZ = new TKeypress(false,
+            0, 'z', false, false, false);
+    public static final TKeypress kbSpace = new TKeypress(false,
+            0, ' ', false, false, false);
+    public static final TKeypress kbAltA = new TKeypress(false,
+            0, 'a', true, false, false);
+    public static final TKeypress kbAltB = new TKeypress(false,
+            0, 'b', true, false, false);
+    public static final TKeypress kbAltC = new TKeypress(false,
+            0, 'c', true, false, false);
+    public static final TKeypress kbAltD = new TKeypress(false,
+            0, 'd', true, false, false);
+    public static final TKeypress kbAltE = new TKeypress(false,
+            0, 'e', true, false, false);
+    public static final TKeypress kbAltF = new TKeypress(false,
+            0, 'f', true, false, false);
+    public static final TKeypress kbAltG = new TKeypress(false,
+            0, 'g', true, false, false);
+    public static final TKeypress kbAltH = new TKeypress(false,
+            0, 'h', true, false, false);
+    public static final TKeypress kbAltI = new TKeypress(false,
+            0, 'i', true, false, false);
+    public static final TKeypress kbAltJ = new TKeypress(false,
+            0, 'j', true, false, false);
+    public static final TKeypress kbAltK = new TKeypress(false,
+            0, 'k', true, false, false);
+    public static final TKeypress kbAltL = new TKeypress(false,
+            0, 'l', true, false, false);
+    public static final TKeypress kbAltM = new TKeypress(false,
+            0, 'm', true, false, false);
+    public static final TKeypress kbAltN = new TKeypress(false,
+            0, 'n', true, false, false);
+    public static final TKeypress kbAltO = new TKeypress(false,
+            0, 'o', true, false, false);
+    public static final TKeypress kbAltP = new TKeypress(false,
+            0, 'p', true, false, false);
+    public static final TKeypress kbAltQ = new TKeypress(false,
+            0, 'q', true, false, false);
+    public static final TKeypress kbAltR = new TKeypress(false,
+            0, 'r', true, false, false);
+    public static final TKeypress kbAltS = new TKeypress(false,
+            0, 's', true, false, false);
+    public static final TKeypress kbAltT = new TKeypress(false,
+            0, 't', true, false, false);
+    public static final TKeypress kbAltU = new TKeypress(false,
+            0, 'u', true, false, false);
+    public static final TKeypress kbAltV = new TKeypress(false,
+            0, 'v', true, false, false);
+    public static final TKeypress kbAltW = new TKeypress(false,
+            0, 'w', true, false, false);
+    public static final TKeypress kbAltX = new TKeypress(false,
+            0, 'x', true, false, false);
+    public static final TKeypress kbAltY = new TKeypress(false,
+            0, 'y', true, false, false);
+    public static final TKeypress kbAltZ = new TKeypress(false,
+            0, 'z', true, false, false);
+    public static final TKeypress kbAlt0 = new TKeypress(false,
+            0, '0', true, false, false);
+    public static final TKeypress kbAlt1 = new TKeypress(false,
+            0, '1', true, false, false);
+    public static final TKeypress kbAlt2 = new TKeypress(false,
+            0, '2', true, false, false);
+    public static final TKeypress kbAlt3 = new TKeypress(false,
+            0, '3', true, false, false);
+    public static final TKeypress kbAlt4 = new TKeypress(false,
+            0, '4', true, false, false);
+    public static final TKeypress kbAlt5 = new TKeypress(false,
+            0, '5', true, false, false);
+    public static final TKeypress kbAlt6 = new TKeypress(false,
+            0, '6', true, false, false);
+    public static final TKeypress kbAlt7 = new TKeypress(false,
+            0, '7', true, false, false);
+    public static final TKeypress kbAlt8 = new TKeypress(false,
+            0, '8', true, false, false);
+    public static final TKeypress kbAlt9 = new TKeypress(false,
+            0, '9', true, false, false);
+    public static final TKeypress kbCtrlA = new TKeypress(false,
+            0, 'A', false, true, false);
+    public static final TKeypress kbCtrlB = new TKeypress(false,
+            0, 'B', false, true, false);
+    public static final TKeypress kbCtrlC = new TKeypress(false,
+            0, 'C', false, true, false);
+    public static final TKeypress kbCtrlD = new TKeypress(false,
+            0, 'D', false, true, false);
+    public static final TKeypress kbCtrlE = new TKeypress(false,
+            0, 'E', false, true, false);
+    public static final TKeypress kbCtrlF = new TKeypress(false,
+            0, 'F', false, true, false);
+    public static final TKeypress kbCtrlG = new TKeypress(false,
+            0, 'G', false, true, false);
+    public static final TKeypress kbCtrlH = new TKeypress(false,
+            0, 'H', false, true, false);
+    public static final TKeypress kbCtrlI = new TKeypress(false,
+            0, 'I', false, true, false);
+    public static final TKeypress kbCtrlJ = new TKeypress(false,
+            0, 'J', false, true, false);
+    public static final TKeypress kbCtrlK = new TKeypress(false,
+            0, 'K', false, true, false);
+    public static final TKeypress kbCtrlL = new TKeypress(false,
+            0, 'L', false, true, false);
+    public static final TKeypress kbCtrlM = new TKeypress(false,
+            0, 'M', false, true, false);
+    public static final TKeypress kbCtrlN = new TKeypress(false,
+            0, 'N', false, true, false);
+    public static final TKeypress kbCtrlO = new TKeypress(false,
+            0, 'O', false, true, false);
+    public static final TKeypress kbCtrlP = new TKeypress(false,
+            0, 'P', false, true, false);
+    public static final TKeypress kbCtrlQ = new TKeypress(false,
+            0, 'Q', false, true, false);
+    public static final TKeypress kbCtrlR = new TKeypress(false,
+            0, 'R', false, true, false);
+    public static final TKeypress kbCtrlS = new TKeypress(false,
+            0, 'S', false, true, false);
+    public static final TKeypress kbCtrlT = new TKeypress(false,
+            0, 'T', false, true, false);
+    public static final TKeypress kbCtrlU = new TKeypress(false,
+            0, 'U', false, true, false);
+    public static final TKeypress kbCtrlV = new TKeypress(false,
+            0, 'V', false, true, false);
+    public static final TKeypress kbCtrlW = new TKeypress(false,
+            0, 'W', false, true, false);
+    public static final TKeypress kbCtrlX = new TKeypress(false,
+            0, 'X', false, true, false);
+    public static final TKeypress kbCtrlY = new TKeypress(false,
+            0, 'Y', false, true, false);
+    public static final TKeypress kbCtrlZ = new TKeypress(false,
+            0, 'Z', false, true, false);
+    public static final TKeypress kbAltShiftA = new TKeypress(false,
+            0, 'A', true, false, true);
+    public static final TKeypress kbAltShiftB = new TKeypress(false,
+            0, 'B', true, false, true);
+    public static final TKeypress kbAltShiftC = new TKeypress(false,
+            0, 'C', true, false, true);
+    public static final TKeypress kbAltShiftD = new TKeypress(false,
+            0, 'D', true, false, true);
+    public static final TKeypress kbAltShiftE = new TKeypress(false,
+            0, 'E', true, false, true);
+    public static final TKeypress kbAltShiftF = new TKeypress(false,
+            0, 'F', true, false, true);
+    public static final TKeypress kbAltShiftG = new TKeypress(false,
+            0, 'G', true, false, true);
+    public static final TKeypress kbAltShiftH = new TKeypress(false,
+            0, 'H', true, false, true);
+    public static final TKeypress kbAltShiftI = new TKeypress(false,
+            0, 'I', true, false, true);
+    public static final TKeypress kbAltShiftJ = new TKeypress(false,
+            0, 'J', true, false, true);
+    public static final TKeypress kbAltShiftK = new TKeypress(false,
+            0, 'K', true, false, true);
+    public static final TKeypress kbAltShiftL = new TKeypress(false,
+            0, 'L', true, false, true);
+    public static final TKeypress kbAltShiftM = new TKeypress(false,
+            0, 'M', true, false, true);
+    public static final TKeypress kbAltShiftN = new TKeypress(false,
+            0, 'N', true, false, true);
+    public static final TKeypress kbAltShiftO = new TKeypress(false,
+            0, 'O', true, false, true);
+    public static final TKeypress kbAltShiftP = new TKeypress(false,
+            0, 'P', true, false, true);
+    public static final TKeypress kbAltShiftQ = new TKeypress(false,
+            0, 'Q', true, false, true);
+    public static final TKeypress kbAltShiftR = new TKeypress(false,
+            0, 'R', true, false, true);
+    public static final TKeypress kbAltShiftS = new TKeypress(false,
+            0, 'S', true, false, true);
+    public static final TKeypress kbAltShiftT = new TKeypress(false,
+            0, 'T', true, false, true);
+    public static final TKeypress kbAltShiftU = new TKeypress(false,
+            0, 'U', true, false, true);
+    public static final TKeypress kbAltShiftV = new TKeypress(false,
+            0, 'V', true, false, true);
+    public static final TKeypress kbAltShiftW = new TKeypress(false,
+            0, 'W', true, false, true);
+    public static final TKeypress kbAltShiftX = new TKeypress(false,
+            0, 'X', true, false, true);
+    public static final TKeypress kbAltShiftY = new TKeypress(false,
+            0, 'Y', true, false, true);
+    public static final TKeypress kbAltShiftZ = new TKeypress(false,
+            0, 'Z', true, false, true);
+
+    public static final TKeypress kbAltShiftHome = new TKeypress(true,
+            TKeypress.HOME, ' ', true, false, true);
+    public static final TKeypress kbAltShiftEnd = new TKeypress(true,
+            TKeypress.END, ' ', true, false, true);
+    public static final TKeypress kbAltShiftPgUp = new TKeypress(true,
+            TKeypress.PGUP, ' ', true, false, true);
+    public static final TKeypress kbAltShiftPgDn = new TKeypress(true,
+            TKeypress.PGDN, ' ', true, false, true);
+    public static final TKeypress kbAltShiftUp = new TKeypress(true,
+            TKeypress.UP, ' ', true, false, true);
+    public static final TKeypress kbAltShiftDown = new TKeypress(true,
+            TKeypress.DOWN, ' ', true, false, true);
+    public static final TKeypress kbAltShiftLeft = new TKeypress(true,
+            TKeypress.LEFT, ' ', true, false, true);
+    public static final TKeypress kbAltShiftRight = new TKeypress(true,
+            TKeypress.RIGHT, ' ', true, false, true);
+
+    public static final TKeypress kbCtrlShiftHome = new TKeypress(true,
+            TKeypress.HOME, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftEnd = new TKeypress(true,
+            TKeypress.END, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftPgUp = new TKeypress(true,
+            TKeypress.PGUP, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftPgDn = new TKeypress(true,
+            TKeypress.PGDN, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftUp = new TKeypress(true,
+            TKeypress.UP, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftDown = new TKeypress(true,
+            TKeypress.DOWN, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftLeft = new TKeypress(true,
+            TKeypress.LEFT, ' ', false, true, true);
+    public static final TKeypress kbCtrlShiftRight = new TKeypress(true,
+            TKeypress.RIGHT, ' ', false, true, true);
+
+
+    /**
+     * Backspace as ^H.
+     */
+    public static final TKeypress kbBackspace = new TKeypress(false,
+            0, 'H', false, true, false);
+
+    /**
+     * Backspace as ^?.
+     */
+    public static final TKeypress kbBackspaceDel = new TKeypress(false,
+            0, (char) 0x7F, false, false, false);
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, ch is meaningless, use keyCode instead.
+     */
+    private boolean isFunctionKey;
+
+    /**
+     * Will be set to F1, F2, HOME, END, etc. if isKey is true.
+     */
+    private int keyCode;
+
+    /**
+     * Keystroke modifier ALT.
+     */
+    private boolean alt;
+
+    /**
+     * Keystroke modifier CTRL.
+     */
+    private boolean ctrl;
+
+    /**
+     * Keystroke modifier SHIFT.
+     */
+    private boolean shift;
+
+    /**
+     * The character received.
+     */
+    private int ch;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor makes an immutable instance.
+     *
+     * @param isKey is true, this is a function key
+     * @param fnKey the function key code (only valid if isKey is true)
+     * @param ch the character (only valid if fnKey is false)
+     * @param alt if true, ALT was pressed with this keystroke
+     * @param ctrl if true, CTRL was pressed with this keystroke
+     * @param shift if true, SHIFT was pressed with this keystroke
+     */
+    public TKeypress(final boolean isKey, final int fnKey, final int ch,
+            final boolean alt, final boolean ctrl, final boolean shift) {
+
+        this.isFunctionKey = isKey;
+        this.keyCode       = fnKey;
+        this.ch            = ch;
+        this.alt           = alt;
+        this.ctrl          = ctrl;
+        this.shift         = shift;
+    }
+
+    // ------------------------------------------------------------------------
+    // TKeypress --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Getter for isFunctionKey.
+     *
+     * @return if true, ch is meaningless, use keyCode instead
+     */
+    public boolean isFnKey() {
+        return isFunctionKey;
+    }
+
+    /**
+     * Getter for function key code.
+     *
+     * @return function key code int value (only valid is isKey is true)
+     */
+    public int getKeyCode() {
+        return keyCode;
+    }
+
+    /**
+     * Getter for ALT.
+     *
+     * @return alt value
+     */
+    public boolean isAlt() {
+        return alt;
+    }
+
+    /**
+     * Getter for CTRL.
+     *
+     * @return ctrl value
+     */
+    public boolean isCtrl() {
+        return ctrl;
+    }
+
+    /**
+     * Getter for SHIFT.
+     *
+     * @return shift value
+     */
+    public boolean isShift() {
+        return shift;
+    }
+
+    /**
+     * Getter for character.
+     *
+     * @return the character (only valid if isKey is false)
+     */
+    public int getChar() {
+        return ch;
+    }
+
+    /**
+     * Create a duplicate instance.
+     *
+     * @return duplicate intance
+     */
+    public TKeypress dup() {
+        TKeypress keypress = new TKeypress(isFunctionKey, keyCode, ch,
+            alt, ctrl, shift);
+        return keypress;
+    }
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another TKeypress instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public boolean equals(final Object rhs) {
+        if (!(rhs instanceof TKeypress)) {
+            return false;
+        }
+
+        TKeypress that = (TKeypress) rhs;
+        return ((isFunctionKey == that.isFunctionKey)
+                && (keyCode == that.keyCode)
+                && (ch == that.ch)
+                && (alt == that.alt)
+                && (ctrl == that.ctrl)
+                && (shift == that.shift));
+    }
+
+    /**
+     * Comparison check, omitting the ctrl/alt/shift flags.
+     *
+     * @param rhs another TKeypress instance
+     * @return true if all fields (except for ctrl/alt/shift) are equal
+     */
+    public boolean equalsWithoutModifiers(final Object rhs) {
+        if (!(rhs instanceof TKeypress)) {
+            return false;
+        }
+
+        TKeypress that = (TKeypress) rhs;
+        return ((isFunctionKey == that.isFunctionKey)
+                && (keyCode == that.keyCode)
+                && (ch == that.ch));
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        int A = 13;
+        int B = 23;
+        int hash = A;
+        hash = (B * hash) + (isFunctionKey ? 1 : 0);
+        hash = (B * hash) + keyCode;
+        hash = (B * hash) + ch;
+        hash = (B * hash) + (alt ? 1 : 0);
+        hash = (B * hash) + (ctrl ? 1 : 0);
+        hash = (B * hash) + (shift ? 1 : 0);
+        return hash;
+    }
+
+    /**
+     * Make human-readable description of this TKeypress.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        // Special case: Enter is "<arrow> <line> <angle>"
+        if (equals(kbEnter)) {
+            return "\u25C0\u2500\u2518";
+        }
+
+        // Special case: Space is "Space"
+        if (equals(kbSpace)) {
+            return "Space";
+        }
+
+        if (equals(kbShiftLeft)) {
+            return "Shift+\u2190";
+        }
+        if (equals(kbShiftRight)) {
+            return "Shift+\u2192";
+        }
+
+        if (isFunctionKey) {
+            switch (keyCode) {
+            case F1:
+                return String.format("%s%s%sF1",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F2:
+                return String.format("%s%s%sF2",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F3:
+                return String.format("%s%s%sF3",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F4:
+                return String.format("%s%s%sF4",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F5:
+                return String.format("%s%s%sF5",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F6:
+                return String.format("%s%s%sF6",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F7:
+                return String.format("%s%s%sF7",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F8:
+                return String.format("%s%s%sF8",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F9:
+                return String.format("%s%s%sF9",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F10:
+                return String.format("%s%s%sF10",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F11:
+                return String.format("%s%s%sF11",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case F12:
+                return String.format("%s%s%sF12",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case HOME:
+                return String.format("%s%s%sHOME",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case END:
+                return String.format("%s%s%sEND",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case PGUP:
+                return String.format("%s%s%sPGUP",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case PGDN:
+                return String.format("%s%s%sPGDN",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case INS:
+                return String.format("%s%s%sINS",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case DEL:
+                return String.format("%s%s%sDEL",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case RIGHT:
+                return String.format("%s%s%sRIGHT",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case LEFT:
+                return String.format("%s%s%sLEFT",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case UP:
+                return String.format("%s%s%sUP",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case DOWN:
+                return String.format("%s%s%sDOWN",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case TAB:
+                return String.format("%s%s%sTAB",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case BTAB:
+                return String.format("%s%s%sBTAB",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case ENTER:
+                return String.format("%s%s%sENTER",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            case ESC:
+                return String.format("%s%s%sESC",
+                        ctrl ? "Ctrl+" : "",
+                                alt ? "Alt+" : "",
+                                        shift ? "Shift+" : "");
+            default:
+                return String.format("--UNKNOWN--");
+            }
+        } else {
+            if (alt && !shift && !ctrl) {
+                // Alt-X
+                return String.format("Alt+%c", Character.toUpperCase(ch));
+            } else if (!alt && shift && !ctrl) {
+                // Shift-X
+                return String.format("%c", ch);
+            } else if (!alt && !shift && ctrl) {
+                // Ctrl-X
+                return String.format("Ctrl+%c", ch);
+            } else if (alt && shift && !ctrl) {
+                // Alt-Shift-X
+                return String.format("Alt+Shift+%c", ch);
+            } else if (!alt && shift && ctrl) {
+                // Ctrl-Shift-X
+                return String.format("Ctrl+Shift+%c", ch);
+            } else if (alt && !shift && ctrl) {
+                // Ctrl-Alt-X
+                return String.format("Ctrl+Alt+%c", Character.toUpperCase(ch));
+            } else if (alt && shift && ctrl) {
+                // Ctrl-Alt-Shift-X
+                return String.format("Ctrl+Alt+Shift+%c",
+                        Character.toUpperCase(ch));
+            } else {
+                // X
+                return String.format("%c", ch);
+            }
+        }
+    }
+
+    /**
+     * Convert a keypress to lowercase.  Function keys and alt/ctrl keys are
+     * not converted.
+     *
+     * @return a new instance with the key converted
+     */
+    public TKeypress toLowerCase() {
+        TKeypress newKey = new TKeypress(isFunctionKey, keyCode, ch, alt, ctrl,
+                shift);
+        if (!isFunctionKey && (ch >= 'A') && (ch <= 'Z') && !ctrl && !alt) {
+            newKey.shift = false;
+            newKey.ch += 32;
+        }
+        return newKey;
+    }
+
+    /**
+     * Convert a keypress to uppercase.  Function keys and alt/ctrl keys are
+     * not converted.
+     *
+     * @return a new instance with the key converted
+     */
+    public TKeypress toUpperCase() {
+        TKeypress newKey = new TKeypress(isFunctionKey, keyCode, ch, alt, ctrl,
+                shift);
+        if (!isFunctionKey && (ch >= 'a') && (ch <= 'z') && !ctrl && !alt) {
+            newKey.shift = true;
+            newKey.ch -= 32;
+        }
+        return newKey;
+    }
+
+}
diff --git a/src/jexer/TLabel.java b/src/jexer/TLabel.java
new file mode 100644 (file)
index 0000000..cc341cf
--- /dev/null
@@ -0,0 +1,275 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.MnemonicString;
+import jexer.bits.StringUtils;
+
+/**
+ * TLabel implements a simple label, with an optional mnemonic hotkey action
+ * associated with it.
+ */
+public class TLabel extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The shortcut and label.
+     */
+    private MnemonicString mnemonic;
+
+    /**
+     * The action to perform when the mnemonic shortcut is pressed.
+     */
+    private TAction action;
+
+    /**
+     * Label color.
+     */
+    private String colorKey;
+
+    /**
+     * If true, use the window's background color.
+     */
+    private boolean useWindowBackground = true;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor, using the default "tlabel" for colorKey.
+     *
+     * @param parent parent widget
+     * @param text label on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     */
+    public TLabel(final TWidget parent, final String text, final int x,
+        final int y) {
+
+        this(parent, text, x, y, "tlabel");
+    }
+
+    /**
+     * Public constructor, using the default "tlabel" for colorKey.
+     *
+     * @param parent parent widget
+     * @param text label on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param action to call when shortcut is pressed
+     */
+    public TLabel(final TWidget parent, final String text, final int x,
+        final int y, final TAction action) {
+
+        this(parent, text, x, y, "tlabel", action);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text label on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text
+     */
+    public TLabel(final TWidget parent, final String text, final int x,
+        final int y, final String colorKey) {
+
+        this(parent, text, x, y, colorKey, true);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text label on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text
+     * @param action to call when shortcut is pressed
+     */
+    public TLabel(final TWidget parent, final String text, final int x,
+        final int y, final String colorKey, final TAction action) {
+
+        this(parent, text, x, y, colorKey, true, action);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text label on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text
+     * @param useWindowBackground if true, use the window's background color
+     */
+    public TLabel(final TWidget parent, final String text, final int x,
+        final int y, final String colorKey, final boolean useWindowBackground) {
+
+        this(parent, text, x, y, colorKey, useWindowBackground, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text label on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text
+     * @param useWindowBackground if true, use the window's background color
+     * @param action to call when shortcut is pressed
+     */
+    public TLabel(final TWidget parent, final String text, final int x,
+        final int y, final String colorKey, final boolean useWindowBackground,
+        final TAction action) {
+
+        // Set parent and window
+        super(parent, false, x, y, 0, 1);
+
+        setLabel(text);
+        this.colorKey = colorKey;
+        this.useWindowBackground = useWindowBackground;
+        this.action = action;
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's width: we can only set width at construction time.
+     *
+     * @param width new widget width (ignored)
+     */
+    @Override
+    public void setWidth(final int width) {
+        // Do nothing
+    }
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw a static label.
+     */
+    @Override
+    public void draw() {
+        // Setup my color
+        CellAttributes color = new CellAttributes();
+        CellAttributes mnemonicColor = new CellAttributes();
+        color.setTo(getTheme().getColor(colorKey));
+        mnemonicColor.setTo(getTheme().getColor("tlabel.mnemonic"));
+        if (useWindowBackground) {
+            CellAttributes background = getWindow().getBackground();
+            color.setBackColor(background.getBackColor());
+            mnemonicColor.setBackColor(background.getBackColor());
+        }
+        putStringXY(0, 0, mnemonic.getRawLabel(), color);
+        if (mnemonic.getScreenShortcutIdx() >= 0) {
+            putCharXY(mnemonic.getScreenShortcutIdx(), 0,
+                mnemonic.getShortcut(), mnemonicColor);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TLabel -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get label raw text.
+     *
+     * @return label text
+     */
+    public String getLabel() {
+        return mnemonic.getRawLabel();
+    }
+
+    /**
+     * Get the mnemonic string for this label.
+     *
+     * @return mnemonic string
+     */
+    public MnemonicString getMnemonic() {
+        return mnemonic;
+    }
+
+    /**
+     * Set label text.
+     *
+     * @param label new label text
+     */
+    public void setLabel(final String label) {
+        mnemonic = new MnemonicString(label);
+        super.setWidth(StringUtils.width(mnemonic.getRawLabel()));
+    }
+
+    /**
+     * Get the label color.
+     *
+     * @return the ColorTheme key color to use for foreground text
+     */
+    public String getColorKey() {
+        return colorKey;
+    }
+
+    /**
+     * Set the label color.
+     *
+     * @param colorKey ColorTheme key color to use for foreground text
+     */
+    public void setColorKey(final String colorKey) {
+        this.colorKey = colorKey;
+    }
+
+    /**
+     * Act as though the mnemonic shortcut was pressed.
+     */
+    public void dispatch() {
+        if (action != null) {
+            action.DO(this);
+        }
+    }
+
+}
diff --git a/src/jexer/TList.java b/src/jexer/TList.java
new file mode 100644 (file)
index 0000000..12e0b8a
--- /dev/null
@@ -0,0 +1,546 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TList shows a list of strings, and lets the user select one.
+ */
+public class TList extends TScrollableWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The list of strings to display.
+     */
+    private List<String> strings;
+
+    /**
+     * Selected string.
+     */
+    private int selectedString = -1;
+
+    /**
+     * Maximum width of a single line.
+     */
+    private int maxLineWidth;
+
+    /**
+     * The action to perform when the user selects an item (double-clicks or
+     * enter).
+     */
+    protected TAction enterAction = null;
+
+    /**
+     * The action to perform when the user selects an item (single-click).
+     */
+    protected TAction singleClickAction = null;
+
+    /**
+     * The action to perform when the user navigates with keyboard.
+     */
+    protected TAction moveAction = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param strings list of strings to show
+     * @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 TList(final TWidget parent, final List<String> strings, final int x,
+        final int y, final int width, final int height) {
+
+        this(parent, strings, x, y, width, height, null, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param strings list of strings to show.  This is allowed to be null
+     * and set later with setList() or by subclasses.
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param enterAction action to perform when an item is selected
+     */
+    public TList(final TWidget parent, final List<String> strings, final int x,
+        final int y, final int width, final int height,
+        final TAction enterAction) {
+
+        this(parent, strings, x, y, width, height, enterAction, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param strings list of strings to show.  This is allowed to be null
+     * and set later with setList() or by subclasses.
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param enterAction action to perform when an item is selected
+     * @param moveAction action to perform when the user navigates to a new
+     * item with arrow/page keys
+     */
+    public TList(final TWidget parent, final List<String> strings, final int x,
+        final int y, final int width, final int height,
+        final TAction enterAction, final TAction moveAction) {
+
+        this(parent, strings, x, y, width, height, enterAction, moveAction,
+            null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param strings list of strings to show.  This is allowed to be null
+     * and set later with setList() or by subclasses.
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param enterAction action to perform when an item is selected
+     * @param moveAction action to perform when the user navigates to a new
+     * item with arrow/page keys
+     * @param singleClickAction action to perform when the user clicks on an
+     * item
+     */
+    public TList(final TWidget parent, final List<String> strings, final int x,
+        final int y, final int width, final int height,
+        final TAction enterAction, final TAction moveAction,
+        final TAction singleClickAction) {
+
+        super(parent, x, y, width, height);
+        this.enterAction = enterAction;
+        this.moveAction = moveAction;
+        this.singleClickAction = singleClickAction;
+        this.strings = new ArrayList<String>();
+        if (strings != null) {
+            this.strings.addAll(strings);
+        }
+
+        hScroller = new THScroller(this, 0, getHeight() - 1, getWidth() - 1);
+        vScroller = new TVScroller(this, getWidth() - 1, 0, getHeight() - 1);
+        reflowData();
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouse.isMouseWheelUp()) {
+            verticalDecrement();
+            return;
+        }
+        if (mouse.isMouseWheelDown()) {
+            verticalIncrement();
+            return;
+        }
+
+        if ((mouse.getX() < getWidth() - 1)
+            && (mouse.getY() < getHeight() - 1)
+        ) {
+            if (getVerticalValue() + mouse.getY() < strings.size()) {
+                selectedString = getVerticalValue() + mouse.getY();
+                dispatchSingleClick();
+            }
+            return;
+        }
+
+        // Pass to children
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse double click.
+     *
+     * @param mouse mouse double click event
+     */
+    @Override
+    public void onMouseDoubleClick(final TMouseEvent mouse) {
+        if ((mouse.getX() < getWidth() - 1)
+            && (mouse.getY() < getHeight() - 1)
+        ) {
+            if (getVerticalValue() + mouse.getY() < strings.size()) {
+                selectedString = getVerticalValue() + mouse.getY();
+                dispatchEnter();
+            }
+            return;
+        }
+
+        // Pass to children
+        super.onMouseDoubleClick(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbLeft)) {
+            horizontalDecrement();
+        } else if (keypress.equals(kbRight)) {
+            horizontalIncrement();
+        } else if (keypress.equals(kbUp)) {
+            if (strings.size() > 0) {
+                if (selectedString >= 0) {
+                    if (selectedString > 0) {
+                        if (selectedString - getVerticalValue() == 0) {
+                            verticalDecrement();
+                        }
+                        selectedString--;
+                    }
+                } else {
+                    selectedString = strings.size() - 1;
+                }
+            }
+            if (selectedString >= 0) {
+                dispatchMove();
+            }
+        } else if (keypress.equals(kbDown)) {
+            if (strings.size() > 0) {
+                if (selectedString >= 0) {
+                    if (selectedString < strings.size() - 1) {
+                        selectedString++;
+                        if (selectedString - getVerticalValue() == getHeight() - 1) {
+                            verticalIncrement();
+                        }
+                    }
+                } else {
+                    selectedString = 0;
+                }
+            }
+            if (selectedString >= 0) {
+                dispatchMove();
+            }
+        } else if (keypress.equals(kbPgUp)) {
+            bigVerticalDecrement();
+            if (selectedString >= 0) {
+                selectedString -= getHeight() - 1;
+                if (selectedString < 0) {
+                    selectedString = 0;
+                }
+            }
+            if (selectedString >= 0) {
+                dispatchMove();
+            }
+        } else if (keypress.equals(kbPgDn)) {
+            bigVerticalIncrement();
+            if (selectedString >= 0) {
+                selectedString += getHeight() - 1;
+                if (selectedString > strings.size() - 1) {
+                    selectedString = strings.size() - 1;
+                }
+            }
+            if (selectedString >= 0) {
+                dispatchMove();
+            }
+        } else if (keypress.equals(kbHome)) {
+            toTop();
+            if (strings.size() > 0) {
+                selectedString = 0;
+            }
+            if (selectedString >= 0) {
+                dispatchMove();
+            }
+        } else if (keypress.equals(kbEnd)) {
+            toBottom();
+            if (strings.size() > 0) {
+                selectedString = strings.size() - 1;
+            }
+            if (selectedString >= 0) {
+                dispatchMove();
+            }
+        } else if (keypress.equals(kbTab)) {
+            getParent().switchWidget(true);
+        } else if (keypress.equals(kbShiftTab) || keypress.equals(kbBackTab)) {
+            getParent().switchWidget(false);
+        } else if (keypress.equals(kbEnter)) {
+            if (selectedString >= 0) {
+                dispatchEnter();
+            }
+        } else {
+            // Pass other keys (tab etc.) on
+            super.onKeypress(keypress);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // 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.
+     *
+     * @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(getHeight() - 1);
+        }
+    }
+
+    /**
+     * Resize for a new width/height.
+     */
+    @Override
+    public void reflowData() {
+
+        // Reset the lines
+        selectedString = -1;
+        maxLineWidth = 0;
+
+        for (int i = 0; i < strings.size(); i++) {
+            String line = strings.get(i);
+            int lineLength = StringUtils.width(line);
+            if (lineLength > maxLineWidth) {
+                maxLineWidth = lineLength;
+            }
+        }
+
+        setBottomValue(strings.size() - getHeight() + 1);
+        if (getBottomValue() < 0) {
+            setBottomValue(0);
+        }
+
+        setRightValue(maxLineWidth - getWidth() + 1);
+        if (getRightValue() < 0) {
+            setRightValue(0);
+        }
+    }
+
+    /**
+     * Draw the list.
+     */
+    @Override
+    public void draw() {
+        CellAttributes color = null;
+        int begin = getVerticalValue();
+        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 {
+                line = "";
+            }
+            if (i == selectedString) {
+                if (isAbsoluteActive()) {
+                    color = getTheme().getColor("tlist.selected");
+                } else {
+                    color = getTheme().getColor("tlist.selected.inactive");
+                }
+            } else if (isAbsoluteActive()) {
+                color = getTheme().getColor("tlist");
+            } else {
+                color = getTheme().getColor("tlist.inactive");
+            }
+            String formatString = "%-" + Integer.toString(getWidth() - 1) + "s";
+            putStringXY(0, topY, String.format(formatString, line), color);
+            topY++;
+            if (topY >= getHeight() - 1) {
+                break;
+            }
+        }
+
+        if (isAbsoluteActive()) {
+            color = getTheme().getColor("tlist");
+        } else {
+            color = getTheme().getColor("tlist.inactive");
+        }
+
+        // Pad the rest with blank lines
+        for (int i = topY; i < getHeight() - 1; i++) {
+            hLineXY(0, i, getWidth() - 1, ' ', color);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TList ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the selection index.
+     *
+     * @return -1 if nothing is selected, otherwise the index into the list
+     */
+    public final int getSelectedIndex() {
+        return selectedString;
+    }
+
+    /**
+     * Set the selected string index.
+     *
+     * @param index -1 to unselect, otherwise the index into the list
+     */
+    public final void setSelectedIndex(final int index) {
+        selectedString = index;
+    }
+
+    /**
+     * Get a selectable string by index.
+     *
+     * @param idx index into list
+     * @return the string at idx in the list
+     */
+    public final String getListItem(final int idx) {
+        return strings.get(idx);
+    }
+
+    /**
+     * Get the selected string.
+     *
+     * @return the selected string, or null of nothing is selected yet
+     */
+    public final String getSelected() {
+        if ((selectedString >= 0) && (selectedString <= strings.size() - 1)) {
+            return strings.get(selectedString);
+        }
+        return null;
+    }
+
+    /**
+     * Get the maximum selection index value.
+     *
+     * @return -1 if the list is empty
+     */
+    public final int getMaxSelectedIndex() {
+        return strings.size() - 1;
+    }
+
+    /**
+     * Get a copy of the list of strings to display.
+     *
+     * @return the list of strings
+     */
+    public final List<String> getList() {
+        return new ArrayList<String>(strings);
+    }
+
+    /**
+     * Set the new list of strings to display.
+     *
+     * @param list new list of strings
+     */
+    public final void setList(final List<String> list) {
+        strings.clear();
+        strings.addAll(list);
+        reflowData();
+    }
+
+    /**
+     * Perform user selection action.
+     */
+    public void dispatchEnter() {
+        assert (selectedString >= 0);
+        assert (selectedString < strings.size());
+        if (enterAction != null) {
+            enterAction.DO(this);
+        }
+    }
+
+    /**
+     * Perform list movement action.
+     */
+    public void dispatchMove() {
+        assert (selectedString >= 0);
+        assert (selectedString < strings.size());
+        if (moveAction != null) {
+            moveAction.DO(this);
+        }
+    }
+
+    /**
+     * Perform single-click action.
+     */
+    public void dispatchSingleClick() {
+        assert (selectedString >= 0);
+        assert (selectedString < strings.size());
+        if (singleClickAction != null) {
+            singleClickAction.DO(this);
+        }
+    }
+
+}
diff --git a/src/jexer/TMessageBox.java b/src/jexer/TMessageBox.java
new file mode 100644 (file)
index 0000000..6f1e8a6
--- /dev/null
@@ -0,0 +1,463 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.ResourceBundle;
+
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TMessageBox is a system-modal dialog with buttons for OK, Cancel, Yes, or
+ * No.  Call it like:
+ *
+ * <pre>
+ * {@code
+ *     box = messageBox(title, caption,
+ *         TMessageBox.Type.OK | TMessageBox.Type.CANCEL);
+ *
+ *     if (box.getResult() == TMessageBox.OK) {
+ *         ... the user pressed OK, do stuff ...
+ *     }
+ * }
+ * </pre>
+ *
+ */
+public class TMessageBox extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TMessageBox.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Message boxes have these supported types.
+     */
+    public enum Type {
+        /**
+         * Show an OK button.
+         */
+        OK,
+
+        /**
+         * Show both OK and Cancel buttons.
+         */
+        OKCANCEL,
+
+        /**
+         * Show both Yes and No buttons.
+         */
+        YESNO,
+
+        /**
+         * Show Yes, No, and Cancel buttons.
+         */
+        YESNOCANCEL
+    };
+
+    /**
+     * Message boxes have these possible results.
+     */
+    public enum Result {
+        /**
+         * User clicked "OK".
+         */
+        OK,
+
+        /**
+         * User clicked "Cancel".
+         */
+        CANCEL,
+
+        /**
+         * User clicked "Yes".
+         */
+        YES,
+
+        /**
+         * User clicked "No".
+         */
+        NO
+    };
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The type of this message box.
+     */
+    private Type type;
+
+    /**
+     * My buttons.
+     */
+    private List<TButton> buttons;
+
+    /**
+     * Which button was clicked: OK, CANCEL, YES, or NO.
+     */
+    private Result result = Result.OK;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  The message box will be centered on screen.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     */
+    public TMessageBox(final TApplication application, final String title,
+        final String caption) {
+
+        this(application, title, caption, Type.OK, true);
+    }
+
+    /**
+     * Public constructor.  The message box will be centered on screen.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param type one of the Type constants.  Default is Type.OK.
+     */
+    public TMessageBox(final TApplication application, final String title,
+        final String caption, final Type type) {
+
+        this(application, title, caption, type, true);
+    }
+
+    /**
+     * Public constructor.  The message box will be centered on screen.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param type one of the Type constants.  Default is Type.OK.
+     * @param yield if true, yield this Thread.  Subclasses need to set this
+     * to false and yield at their end of their constructor intead.
+     */
+    protected TMessageBox(final TApplication application, final String title,
+        final String caption, final Type type, final boolean yield) {
+
+        // Start as 100x100 at (1, 1).  These will be changed later.
+        super(application, title, 1, 1, 100, 100, CENTERED | MODAL);
+
+        // Hang onto type so that we can provide more convenience in
+        // onKeypress().
+        this.type = type;
+
+        // Determine width and height
+        String [] lines = caption.split("\n");
+        int width = StringUtils.width(title) + 12;
+        setHeight(6 + lines.length);
+        for (String line: lines) {
+            if (StringUtils.width(line) + 4 > width) {
+                width = StringUtils.width(line) + 4;
+            }
+        }
+        setWidth(width);
+        if (getWidth() > getScreen().getWidth()) {
+            setWidth(getScreen().getWidth());
+        }
+        // Re-center window to get an appropriate (x, y)
+        center();
+
+        // Now add my elements
+        int lineI = 1;
+        for (String line: lines) {
+            addLabel(line, 1, lineI, "twindow.background.modal");
+            lineI++;
+        }
+
+        // The button line
+        lineI++;
+        buttons = new ArrayList<TButton>();
+
+        int buttonX = 0;
+
+        // Setup button actions
+        switch (type) {
+
+        case OK:
+            result = Result.OK;
+            if (getWidth() < 15) {
+                setWidth(15);
+            }
+            buttonX = (getWidth() - 11) / 2;
+            buttons.add(addButton(i18n.getString("okButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.OK;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            break;
+
+        case OKCANCEL:
+            result = Result.CANCEL;
+            if (getWidth() < 26) {
+                setWidth(26);
+            }
+            buttonX = (getWidth() - 22) / 2;
+            buttons.add(addButton(i18n.getString("okButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.OK;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            buttonX += 8 + 4;
+            buttons.add(addButton(i18n.getString("cancelButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.CANCEL;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            break;
+
+        case YESNO:
+            result = Result.NO;
+            if (getWidth() < 20) {
+                setWidth(20);
+            }
+            buttonX = (getWidth() - 16) / 2;
+            buttons.add(addButton(i18n.getString("yesButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.YES;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            buttonX += 5 + 4;
+            buttons.add(addButton(i18n.getString("noButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.NO;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            break;
+
+        case YESNOCANCEL:
+            result = Result.CANCEL;
+            if (getWidth() < 31) {
+                setWidth(31);
+            }
+            buttonX = (getWidth() - 27) / 2;
+            buttons.add(addButton(i18n.getString("yesButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.YES;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            buttonX += 5 + 4;
+            buttons.add(addButton(i18n.getString("noButton"), buttonX, lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.NO;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            buttonX += 4 + 4;
+            buttons.add(addButton(i18n.getString("cancelButton"), buttonX,
+                    lineI,
+                    new TAction() {
+                        public void DO() {
+                            result = Result.CANCEL;
+                            getApplication().closeWindow(TMessageBox.this);
+                        }
+                    }
+                )
+            );
+            break;
+
+        default:
+            throw new IllegalArgumentException("Invalid message box type: " +
+                type);
+        }
+
+        if (yield) {
+            // Set the secondaryThread to run me
+            getApplication().enableSecondaryEventReceiver(this);
+
+            // Yield to the secondary thread.  When I come back from the
+            // constructor response will already be set.
+            getApplication().yield();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+
+        if (this instanceof TInputBox) {
+            super.onKeypress(keypress);
+            return;
+        }
+
+        // Some convenience for message boxes: Alt won't be needed for the
+        // buttons.
+        switch (type) {
+
+        case OK:
+            if (keypress.equals(kbO)) {
+                buttons.get(0).dispatch();
+                return;
+            }
+            break;
+
+        case OKCANCEL:
+            if (keypress.equals(kbO)) {
+                buttons.get(0).dispatch();
+                return;
+            } else if (keypress.equals(kbC)) {
+                buttons.get(1).dispatch();
+                return;
+            }
+            break;
+
+        case YESNO:
+            if (keypress.equals(kbY)) {
+                buttons.get(0).dispatch();
+                return;
+            } else if (keypress.equals(kbN)) {
+                buttons.get(1).dispatch();
+                return;
+            }
+            break;
+
+        case YESNOCANCEL:
+            if (keypress.equals(kbY)) {
+                buttons.get(0).dispatch();
+                return;
+            } else if (keypress.equals(kbN)) {
+                buttons.get(1).dispatch();
+                return;
+            } else if (keypress.equals(kbC)) {
+                buttons.get(2).dispatch();
+                return;
+            }
+            break;
+
+        default:
+            throw new IllegalArgumentException("Invalid message box type: " +
+                type);
+        }
+
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TMessageBox ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the result.
+     *
+     * @return the result: OK, CANCEL, YES, or NO.
+     */
+    public final Result getResult() {
+        return result;
+    }
+
+    /**
+     * See if the user clicked YES.
+     *
+     * @return true if the user clicked YES
+     */
+    public final boolean isYes() {
+        return (result == Result.YES);
+    }
+
+    /**
+     * See if the user clicked NO.
+     *
+     * @return true if the user clicked NO
+     */
+    public final boolean isNo() {
+        return (result == Result.NO);
+    }
+
+    /**
+     * See if the user clicked OK.
+     *
+     * @return true if the user clicked OK
+     */
+    public final boolean isOk() {
+        return (result == Result.OK);
+    }
+
+    /**
+     * See if the user clicked CANCEL.
+     *
+     * @return true if the user clicked CANCEL
+     */
+    public final boolean isCancel() {
+        return (result == Result.CANCEL);
+    }
+
+}
diff --git a/src/jexer/TMessageBox.properties b/src/jexer/TMessageBox.properties
new file mode 100644 (file)
index 0000000..04e344a
--- /dev/null
@@ -0,0 +1,4 @@
+okButton=\ \ &OK\ \ 
+cancelButton=&Cancel
+yesButton=&Yes
+noButton=&No
diff --git a/src/jexer/TPanel.java b/src/jexer/TPanel.java
new file mode 100644 (file)
index 0000000..c38f8e1
--- /dev/null
@@ -0,0 +1,100 @@
+/*
+ * 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 jexer.event.TResizeEvent;
+
+/**
+ * TPanel is an empty container for other widgets.
+ */
+public class TPanel extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    public TPanel(final TWidget parent, final int x, final int y,
+        final int width, final int height) {
+
+        super(parent, x, y, width, height);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Method that subclasses can override to handle window/screen resize
+     * events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            if (getChildren().size() == 1) {
+                TWidget child = getChildren().get(0);
+                if ((child instanceof TSplitPane)
+                    || (child instanceof TPanel)
+                ) {
+                    child.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                            resize.getWidth(), resize.getHeight()));
+                }
+                return;
+            }
+        }
+
+        // Pass on to TWidget.
+        super.onResize(resize);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+
+    // ------------------------------------------------------------------------
+    // TPanel -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+
+}
diff --git a/src/jexer/TPasswordField.java b/src/jexer/TPasswordField.java
new file mode 100644 (file)
index 0000000..0be2b98
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+
+/**
+ * TPasswordField implements an editable text field that displays
+ * stars/asterisks when it is not active.
+ */
+public class TPasswordField extends TField {
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     */
+    public TPasswordField(final TWidget parent, final int x, final int y,
+        final int width, final boolean fixed) {
+
+        this(parent, x, y, width, fixed, "", null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     */
+    public TPasswordField(final TWidget parent, final int x, final int y,
+        final int width, final boolean fixed, final String text) {
+
+        this(parent, x, y, width, fixed, text, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     * @param enterAction function to call when enter key is pressed
+     * @param updateAction function to call when the text is updated
+     */
+    public TPasswordField(final TWidget parent, final int x, final int y,
+        final int width, final boolean fixed, final String text,
+        final TAction enterAction, final TAction updateAction) {
+
+        // Set parent and window
+        super(parent, x, y, width, fixed, text, enterAction, updateAction);
+    }
+
+    // ------------------------------------------------------------------------
+    // TField -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the text field.
+     */
+    @Override
+    public void draw() {
+        CellAttributes fieldColor;
+
+        boolean showStars = false;
+        if (isAbsoluteActive()) {
+            fieldColor = getTheme().getColor("tfield.active");
+        } else {
+            fieldColor = getTheme().getColor("tfield.inactive");
+            showStars = true;
+        }
+
+        int end = windowStart + getWidth();
+        if (end > StringUtils.width(text)) {
+            end = StringUtils.width(text);
+        }
+
+        hLineXY(0, 0, getWidth(), backgroundChar, fieldColor);
+        if (showStars) {
+            hLineXY(0, 0, getWidth() - 2, '*', fieldColor);
+        } else {
+            putStringXY(0, 0, text.substring(screenToTextPosition(windowStart),
+                    screenToTextPosition(end)), fieldColor);
+        }
+
+        // Fix the cursor, it will be rendered by TApplication.drawAll().
+        updateCursor();
+    }
+
+}
diff --git a/src/jexer/TProgressBar.java b/src/jexer/TProgressBar.java
new file mode 100644 (file)
index 0000000..38f0337
--- /dev/null
@@ -0,0 +1,294 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+
+/**
+ * TProgressBar implements a simple progress bar.
+ */
+public class TProgressBar extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Value that corresponds to 0% progress.
+     */
+    private int minValue = 0;
+
+    /**
+     * Value that corresponds to 100% progress.
+     */
+    private int maxValue = 100;
+
+    /**
+     * Current value of the progress.
+     */
+    private int value = 0;
+
+    /**
+     * The left border character.
+     */
+    private int leftBorderChar = GraphicsChars.CP437[0xC3];
+
+    /**
+     * The filled-in part of the bar.
+     */
+    private int completedChar = GraphicsChars.BOX;
+
+    /**
+     * The remaining to be filled in part of the bar.
+     */
+    private int remainingChar = GraphicsChars.SINGLE_BAR;
+
+    /**
+     * The right border character.
+     */
+    private int rightBorderChar = GraphicsChars.CP437[0xB4];
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of progress bar
+     * @param value initial value of percent complete
+     */
+    public TProgressBar(final TWidget parent, final int x, final int y,
+        final int width, final int value) {
+
+        // Set parent and window
+        super(parent, false, x, y, width, 1);
+
+        this.value = value;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw a static progress bar.
+     */
+    @Override
+    public void draw() {
+
+        if (getWidth() <= 2) {
+            // Bail out, we are too narrow to draw anything.
+            return;
+        }
+
+        CellAttributes completeColor = getTheme().getColor("tprogressbar.complete");
+        CellAttributes incompleteColor = getTheme().getColor("tprogressbar.incomplete");
+
+        float progress = ((float)value - minValue) / ((float)maxValue - minValue);
+        int progressInt = (int)(progress * 100);
+        int progressUnit = 100 / (getWidth() - 2);
+
+        putCharXY(0, 0, leftBorderChar, incompleteColor);
+        for (int i = StringUtils.width(leftBorderChar); i < getWidth() - 2;) {
+            float iProgress = (float)i / (getWidth() - 2);
+            int iProgressInt = (int)(iProgress * 100);
+            if (iProgressInt <= progressInt - progressUnit) {
+                putCharXY(i, 0, completedChar, completeColor);
+                i += StringUtils.width(completedChar);
+            } else {
+                putCharXY(i, 0, remainingChar, incompleteColor);
+                i += StringUtils.width(remainingChar);
+            }
+        }
+        if (value >= maxValue) {
+            putCharXY(getWidth() - StringUtils.width(leftBorderChar) -
+                StringUtils.width(rightBorderChar), 0, completedChar,
+                completeColor);
+        } else {
+            putCharXY(getWidth() - StringUtils.width(leftBorderChar) -
+                StringUtils.width(rightBorderChar), 0, remainingChar,
+                incompleteColor);
+        }
+        putCharXY(getWidth() - StringUtils.width(rightBorderChar), 0,
+            rightBorderChar, incompleteColor);
+    }
+
+    // ------------------------------------------------------------------------
+    // TProgressBar -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the value that corresponds to 0% progress.
+     *
+     * @return the value that corresponds to 0% progress
+     */
+    public int getMinValue() {
+        return minValue;
+    }
+
+    /**
+     * Set the value that corresponds to 0% progress.
+     *
+     * @param minValue the value that corresponds to 0% progress
+     */
+    public void setMinValue(final int minValue) {
+        this.minValue = minValue;
+    }
+
+    /**
+     * Get the value that corresponds to 100% progress.
+     *
+     * @return the value that corresponds to 100% progress
+     */
+    public int getMaxValue() {
+        return maxValue;
+    }
+
+    /**
+     * Set the value that corresponds to 100% progress.
+     *
+     * @param maxValue the value that corresponds to 100% progress
+     */
+    public void setMaxValue(final int maxValue) {
+        this.maxValue = maxValue;
+    }
+
+    /**
+     * Get the current value of the progress.
+     *
+     * @return the current value of the progress
+     */
+    public int getValue() {
+        return value;
+    }
+
+    /**
+     * Set the current value of the progress.
+     *
+     * @param value the current value of the progress
+     */
+    public void setValue(final int value) {
+        this.value = value;
+    }
+
+    /**
+     * Set the left border character.
+     *
+     * @param ch the char to use
+     */
+    public void setLeftBorderChar(final int ch) {
+        leftBorderChar = ch;
+    }
+
+    /**
+     * Get the left border character.
+     *
+     * @return the char
+     */
+    public int getLeftBorderChar() {
+        return leftBorderChar;
+    }
+
+    /**
+     * Set the filled-in part of the bar.
+     *
+     * @param ch the char to use
+     */
+    public void setCompletedChar(final int ch) {
+        completedChar = ch;
+    }
+
+    /**
+     * Get the filled-in part of the bar.
+     *
+     * @return the char
+     */
+    public int getCompletedChar() {
+        return completedChar;
+    }
+
+    /**
+     * Set the remaining to be filled in part of the bar.
+     *
+     * @param ch the char to use
+     */
+    public void setRemainingChar(final int ch) {
+        remainingChar = ch;
+    }
+
+    /**
+     * Get the remaining to be filled in part of the bar.
+     *
+     * @return the char
+     */
+    public int getRemainingChar() {
+        return remainingChar;
+    }
+
+    /**
+     * Set the right border character.
+     *
+     * @param ch the char to use
+     */
+    public void setRightBorderChar(final int ch) {
+        rightBorderChar = ch;
+    }
+
+    /**
+     * Get the right border character.
+     *
+     * @return the char
+     */
+    public int getRightBorderChar() {
+        return rightBorderChar;
+    }
+
+}
diff --git a/src/jexer/TRadioButton.java b/src/jexer/TRadioButton.java
new file mode 100644 (file)
index 0000000..dcc5c13
--- /dev/null
@@ -0,0 +1,257 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.MnemonicString;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TRadioButton implements a selectable radio button.
+ *
+ * If the user clicks or presses space on this button, it is selected.
+ *
+ * If the user presses escape on this button, it is unselected.
+ */
+public class TRadioButton extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * RadioButton state, true means selected.  Note package private access.
+     */
+    boolean selected = false;
+
+    /**
+     * The shortcut and radio button label.
+     */
+    private MnemonicString mnemonic;
+
+    /**
+     * ID for this radio button.  Buttons start counting at 1 in the
+     * RadioGroup.  Note package private access.
+     */
+    int id;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label label to display next to (right of) the radiobutton
+     * @param id ID for this radio button
+     */
+    TRadioButton(final TRadioGroup parent, final int x, final int y,
+        final String label, final int id) {
+
+        // Set parent and window
+        super(parent, x, y, StringUtils.width(label) + 4, 1);
+
+        mnemonic = new MnemonicString(label);
+        this.id = id;
+
+        setCursorVisible(true);
+        setCursorX(1);
+
+        parent.addRadioButton(this);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the radio button.
+     *
+     * @param mouse mouse event
+     * @return if true the mouse is currently on the radio button
+     */
+    private boolean mouseOnRadioButton(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() >= 0)
+            && (mouse.getX() <= 2)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if ((mouseOnRadioButton(mouse)) && (mouse.isMouse1())) {
+            // Switch state
+            ((TRadioGroup) getParent()).setSelected(id);
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+
+        if (keypress.equals(kbSpace)) {
+            ((TRadioGroup) getParent()).setSelected(id);
+            return;
+        }
+
+        if (keypress.equals(kbEsc)) {
+            TRadioGroup parent = (TRadioGroup) getParent();
+            if (parent.requiresSelection == false) {
+                selected = false;
+                parent.setSelected(0);
+            }
+            return;
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's width: we can only set width at construction time.
+     *
+     * @param width new widget width (ignored)
+     */
+    @Override
+    public void setWidth(final int width) {
+        // Do nothing
+    }
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw a radio button with label.
+     */
+    @Override
+    public void draw() {
+        CellAttributes radioButtonColor;
+        CellAttributes mnemonicColor;
+
+        if (isAbsoluteActive()) {
+            radioButtonColor = getTheme().getColor("tradiobutton.active");
+            mnemonicColor = getTheme().getColor("tradiobutton.mnemonic.highlighted");
+        } else {
+            radioButtonColor = getTheme().getColor("tradiobutton.inactive");
+            mnemonicColor = getTheme().getColor("tradiobutton.mnemonic");
+        }
+
+        putCharXY(0, 0, '(', radioButtonColor);
+        if (selected) {
+            putCharXY(1, 0, GraphicsChars.CP437[0x07], radioButtonColor);
+        } else {
+            putCharXY(1, 0, ' ', radioButtonColor);
+        }
+        putCharXY(2, 0, ')', radioButtonColor);
+        putStringXY(4, 0, mnemonic.getRawLabel(), radioButtonColor);
+        if (mnemonic.getScreenShortcutIdx() >= 0) {
+            putCharXY(4 + mnemonic.getScreenShortcutIdx(), 0,
+                mnemonic.getShortcut(), mnemonicColor);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TRadioButton -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get RadioButton state, true means selected.
+     *
+     * @return if true then this is the one button in the group that is
+     * selected
+     */
+    public boolean isSelected() {
+        return selected;
+    }
+
+    /**
+     * Set RadioButton state, true means selected.
+     *
+     * @param selected if true then this is the one button in the group that
+     * is selected
+     */
+    public void setSelected(final boolean selected) {
+        if (selected == true) {
+            ((TRadioGroup) getParent()).setSelected(id);
+        } else {
+            ((TRadioGroup) getParent()).setSelected(0);
+        }
+    }
+
+    /**
+     * Get ID for this radio button.  Buttons start counting at 1 in the
+     * RadioGroup.
+     *
+     * @return the ID
+     */
+    public int getId() {
+        return id;
+    }
+
+    /**
+     * Get the mnemonic string for this button.
+     *
+     * @return mnemonic string
+     */
+    public MnemonicString getMnemonic() {
+        return mnemonic;
+    }
+
+}
diff --git a/src/jexer/TRadioGroup.java b/src/jexer/TRadioGroup.java
new file mode 100644 (file)
index 0000000..d6bd7ff
--- /dev/null
@@ -0,0 +1,266 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+
+/**
+ * TRadioGroup is a collection of TRadioButtons with a box and label.
+ */
+public class TRadioGroup extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Label for this radio button group.
+     */
+    private String label;
+
+    /**
+     * Only one of my children can be selected.
+     */
+    private TRadioButton selectedButton = null;
+
+    /**
+     * If true, one of the children MUST be selected.  Note package private
+     * access.
+     */
+    boolean requiresSelection = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of group
+     * @param label label to display on the group box
+     */
+    public TRadioGroup(final TWidget parent, final int x, final int y,
+        final int width, final String label) {
+
+        // Set parent and window
+        super(parent, x, y, width, 2);
+
+        this.label = label;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label label to display on the group box
+     */
+    public TRadioGroup(final TWidget parent, final int x, final int y,
+        final String label) {
+
+        // Set parent and window
+        super(parent, x, y, StringUtils.width(label) + 4, 2);
+
+        this.label = label;
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Override TWidget's width: we can only set width at construction time.
+     *
+     * @param width new widget width (ignored)
+     */
+    @Override
+    public void setWidth(final int width) {
+        // Do nothing
+    }
+
+    /**
+     * Override TWidget's height: we can only set height at construction
+     * time.
+     *
+     * @param height new widget height (ignored)
+     */
+    @Override
+    public void setHeight(final int height) {
+        // Do nothing
+    }
+
+    /**
+     * Draw a radio button with label.
+     */
+    @Override
+    public void draw() {
+        CellAttributes radioGroupColor;
+
+        if (isAbsoluteActive()) {
+            radioGroupColor = getTheme().getColor("tradiogroup.active");
+        } else {
+            radioGroupColor = getTheme().getColor("tradiogroup.inactive");
+        }
+
+        drawBox(0, 0, getWidth(), getHeight(), radioGroupColor, radioGroupColor,
+            3, false);
+
+        hLineXY(1, 0, StringUtils.width(label) + 2, ' ', radioGroupColor);
+        putStringXY(2, 0, label, radioGroupColor);
+    }
+
+    // ------------------------------------------------------------------------
+    // TRadioGroup ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the radio button ID that was selected.
+     *
+     * @return ID of the selected button, or 0 if no button is selected
+     */
+    public int getSelected() {
+        if (selectedButton == null) {
+            return 0;
+        }
+        return selectedButton.getId();
+    }
+
+    /**
+     * Set the new selected radio button.  1-based.
+     *
+     * @param id ID of the selected button, or 0 to unselect
+     */
+    public void setSelected(final int id) {
+        if ((id < 0) || (id > getChildren().size())) {
+            return;
+        }
+
+        for (TWidget widget: getChildren()) {
+            ((TRadioButton) widget).selected = false;
+        }
+        if (id == 0) {
+            selectedButton = null;
+            return;
+        }
+        assert ((id > 0) && (id <= getChildren().size()));
+        TRadioButton button = (TRadioButton) (getChildren().get(id - 1));
+        button.selected = true;
+        selectedButton = button;
+    }
+
+    /**
+     * Get the radio button that was selected.
+     *
+     * @return the selected button, or null if no button is selected
+     */
+    public TRadioButton getSelectedButton() {
+        return selectedButton;
+    }
+
+    /**
+     * Convenience function to add a radio button to this group.
+     *
+     * @param label label to display next to (right of) the radiobutton
+     * @param selected if true, this will be the selected radiobutton
+     * @return the new radio button
+     */
+    public TRadioButton addRadioButton(final String label,
+        final boolean selected) {
+
+        TRadioButton button = addRadioButton(label);
+        setSelected(button.id);
+        return button;
+    }
+
+    /**
+     * Convenience function to add a radio button to this group.
+     *
+     * @param label label to display next to (right of) the radiobutton
+     * @return the new radio button
+     */
+    public TRadioButton addRadioButton(final String label) {
+        return new TRadioButton(this, 0, 0, label, 0);
+    }
+
+    /**
+     * Package private method for RadioButton to add itself to a RadioGroup
+     * container.
+     *
+     * @param button the button to add
+     */
+    void addRadioButton(final TRadioButton button) {
+        super.setHeight(getChildren().size() + 2);
+        button.setX(1);
+        button.setY(getChildren().size());
+        button.id = getChildren().size();
+        String label = button.getMnemonic().getRawLabel();
+
+        if (StringUtils.width(label) + 4 > getWidth()) {
+            super.setWidth(StringUtils.width(label) + 7);
+        }
+
+        if (getParent().getLayoutManager() != null) {
+            getParent().getLayoutManager().resetSize(this);
+        }
+
+        // Default to the first item on the list.
+        activate(getChildren().get(0));
+    }
+
+    /**
+     * Get the requires selection flag.
+     *
+     * @return true if this radiogroup requires that one of the buttons be
+     * selected
+     */
+    public boolean getRequiresSelection() {
+        return requiresSelection;
+    }
+
+    /**
+     * Set the requires selection flag.
+     *
+     * @param requiresSelection if true, then this radiogroup requires that
+     * one of the buttons be selected
+     */
+    public void setRequiresSelection(final boolean requiresSelection) {
+        this.requiresSelection = requiresSelection;
+        if (requiresSelection) {
+            if ((getChildren().size() > 0) && (selectedButton == null)) {
+                setSelected(1);
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/TScrollableWidget.java b/src/jexer/TScrollableWidget.java
new file mode 100644 (file)
index 0000000..7d15b28
--- /dev/null
@@ -0,0 +1,609 @@
+/*
+ * 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 jexer.event.TResizeEvent;
+
+/**
+ * TScrollableWidget is a convenience superclass for widgets that have
+ * scrollbars.
+ */
+public class TScrollableWidget extends TWidget implements Scrollable {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The horizontal scrollbar.
+     */
+    protected THScroller hScroller = null;
+
+    /**
+     * The vertical scrollbar.
+     */
+    protected TVScroller vScroller = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Protected constructor.
+     *
+     * @param parent parent widget
+     */
+    protected TScrollableWidget(final TWidget parent) {
+        super(parent);
+    }
+
+    /**
+     * Protected constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    protected TScrollableWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height) {
+
+        super(parent, x, y, width, height);
+    }
+
+    /**
+     * Protected constructor used by subclasses that are disabled by default.
+     *
+     * @param parent parent widget
+     * @param enabled if true assume enabled
+     */
+    protected TScrollableWidget(final TWidget parent, final boolean enabled) {
+
+        super(parent, enabled);
+    }
+
+    /**
+     * Protected constructor used by subclasses that are disabled by default.
+     *
+     * @param parent parent widget
+     * @param enabled if true assume enabled
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    protected TScrollableWidget(final TWidget parent, final boolean enabled,
+        final int x, final int y, final int width, final int height) {
+
+        super(parent, enabled, x, y, width, height);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            setWidth(event.getWidth());
+            setHeight(event.getHeight());
+
+            reflowData();
+            placeScrollbars();
+            return;
+        } else {
+            super.onResize(event);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TScrollableWidget ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * 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() - 1);
+            vScroller.setBigChange(getHeight() - 1);
+        }
+    }
+
+    /**
+     * Recompute whatever data is displayed by this widget.
+     */
+    public void reflowData() {
+        // Default: nothing to do
+    }
+
+    /**
+     * Get the horizontal scrollbar, or null if this Viewport does not
+     * support horizontal scrolling.
+     *
+     * @return the horizontal scrollbar
+     */
+    public THScroller getHorizontalScroller() {
+        return hScroller;
+    }
+
+    /**
+     * Get the vertical scrollbar, or null if this Viewport does not support
+     * vertical scrolling.
+     *
+     * @return the vertical scrollbar
+     */
+    public TVScroller getVerticalScroller() {
+        return vScroller;
+    }
+
+    /**
+     * Get the value that corresponds to being on the top edge of the
+     * vertical scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getTopValue() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getTopValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the top edge of the
+     * vertical scroll bar.
+     *
+     * @param topValue the new scroll value
+     */
+    public void setTopValue(final int topValue) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setTopValue(topValue);
+        }
+    }
+
+    /**
+     * Get the value that corresponds to being on the bottom edge of the
+     * vertical scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getBottomValue() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getBottomValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the bottom edge of the
+     * vertical scroll bar.
+     *
+     * @param bottomValue the new scroll value
+     */
+    public void setBottomValue(final int bottomValue) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setBottomValue(bottomValue);
+        }
+    }
+
+    /**
+     * Get current value of the vertical scroll.
+     *
+     * @return the scroll value
+     */
+    public int getVerticalValue() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getValue();
+        }
+    }
+
+    /**
+     * Set current value of the vertical scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setVerticalValue(final int value) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setValue(value);
+        }
+    }
+
+    /**
+     * Get the increment for clicking on an arrow on the vertical scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getVerticalSmallChange() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getSmallChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking on an arrow on the vertical scrollbar.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setVerticalSmallChange(final int smallChange) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setSmallChange(smallChange);
+        }
+    }
+
+    /**
+     * Get the increment for clicking in the bar between the box and an
+     * arrow on the vertical scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getVerticalBigChange() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getBigChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow on the vertical scrollbar.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setVerticalBigChange(final int bigChange) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setBigChange(bigChange);
+        }
+    }
+
+    /**
+     * Perform a small step change up.
+     */
+    public void verticalDecrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.decrement();
+        }
+    }
+
+    /**
+     * Perform a small step change down.
+     */
+    public void verticalIncrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.increment();
+        }
+    }
+
+    /**
+     * Perform a big step change up.
+     */
+    public void bigVerticalDecrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.bigDecrement();
+        }
+    }
+
+    /**
+     * Perform a big step change down.
+     */
+    public void bigVerticalIncrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.bigIncrement();
+        }
+    }
+
+    /**
+     * Go to the top edge of the vertical scroller.
+     */
+    public void toTop() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.toTop();
+        }
+    }
+
+    /**
+     * Go to the bottom edge of the vertical scroller.
+     */
+    public void toBottom() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.toBottom();
+        }
+    }
+
+    /**
+     * Get the value that corresponds to being on the left edge of the
+     * horizontal scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getLeftValue() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getLeftValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the left edge of the
+     * horizontal scroll bar.
+     *
+     * @param leftValue the new scroll value
+     */
+    public void setLeftValue(final int leftValue) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setLeftValue(leftValue);
+        }
+    }
+
+    /**
+     * Get the value that corresponds to being on the right edge of the
+     * horizontal scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getRightValue() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getRightValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the right edge of the
+     * horizontal scroll bar.
+     *
+     * @param rightValue the new scroll value
+     */
+    public void setRightValue(final int rightValue) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setRightValue(rightValue);
+        }
+    }
+
+    /**
+     * Get current value of the horizontal scroll.
+     *
+     * @return the scroll value
+     */
+    public int getHorizontalValue() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getValue();
+        }
+    }
+
+    /**
+     * Set current value of the horizontal scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setHorizontalValue(final int value) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setValue(value);
+        }
+    }
+
+    /**
+     * Get the increment for clicking on an arrow on the horizontal
+     * scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getHorizontalSmallChange() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getSmallChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking on an arrow on the horizontal
+     * scrollbar.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setHorizontalSmallChange(final int smallChange) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setSmallChange(smallChange);
+        }
+    }
+
+    /**
+     * Get the increment for clicking in the bar between the box and an
+     * arrow on the horizontal scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getHorizontalBigChange() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getBigChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow on the horizontal scrollbar.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setHorizontalBigChange(final int bigChange) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setBigChange(bigChange);
+        }
+    }
+
+    /**
+     * Perform a small step change left.
+     */
+    public void horizontalDecrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.decrement();
+        }
+    }
+
+    /**
+     * Perform a small step change right.
+     */
+    public void horizontalIncrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.increment();
+        }
+    }
+
+    /**
+     * Perform a big step change left.
+     */
+    public void bigHorizontalDecrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.bigDecrement();
+        }
+    }
+
+    /**
+     * Perform a big step change right.
+     */
+    public void bigHorizontalIncrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.bigIncrement();
+        }
+    }
+
+    /**
+     * Go to the left edge of the horizontal scroller.
+     */
+    public void toLeft() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.toLeft();
+        }
+    }
+
+    /**
+     * Go to the right edge of the horizontal scroller.
+     */
+    public void toRight() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.toRight();
+        }
+    }
+
+    /**
+     * Go to the top-left edge of the horizontal and vertical scrollers.
+     */
+    public void toHome() {
+        if (hScroller != null) {
+            hScroller.toLeft();
+        }
+        if (vScroller != null) {
+            vScroller.toTop();
+        }
+    }
+
+    /**
+     * Go to the bottom-right edge of the horizontal and vertical scrollers.
+     */
+    public void toEnd() {
+        if (hScroller != null) {
+            hScroller.toRight();
+        }
+        if (vScroller != null) {
+            vScroller.toBottom();
+        }
+    }
+
+}
diff --git a/src/jexer/TScrollableWindow.java b/src/jexer/TScrollableWindow.java
new file mode 100644 (file)
index 0000000..1e260b3
--- /dev/null
@@ -0,0 +1,680 @@
+/*
+ * 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 jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+/**
+ * TScrollableWindow is a convenience superclass for windows that have
+ * scrollbars.
+ */
+public class TScrollableWindow extends TWindow implements Scrollable {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The horizontal scrollbar.
+     */
+    protected THScroller hScroller = null;
+
+    /**
+     * The vertical scrollbar.
+     */
+    protected TVScroller vScroller = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  Window will be located at (0, 0).
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     */
+    public TScrollableWindow(final TApplication application, final String title,
+        final int width, final int height) {
+
+        super(application, title, width, height);
+    }
+
+    /**
+     * Public constructor.  Window will be located at (0, 0).
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+     */
+    public TScrollableWindow(final TApplication application, final String title,
+        final int width, final int height, final int flags) {
+
+        super(application, title, width, height, flags);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     */
+    public TScrollableWindow(final TApplication application, final String title,
+        final int x, final int y, final int width, final int height) {
+
+        super(application, title, x, y, width, height);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     * @param flags mask of RESIZABLE, CENTERED, or MODAL
+     */
+    public TScrollableWindow(final TApplication application, final String title,
+        final int x, final int y, final int width, final int height,
+        final int flags) {
+
+        super(application, title, x, y, width, height, flags);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            reflowData();
+            placeScrollbars();
+            return;
+        } else {
+            super.onResize(event);
+        }
+    }
+
+    /**
+     * Maximize window.
+     */
+    @Override
+    public void maximize() {
+        super.maximize();
+        placeScrollbars();
+    }
+
+    /**
+     * Restore (unmaximize) window.
+     */
+    @Override
+    public void restore() {
+        super.restore();
+        placeScrollbars();
+    }
+
+    // ------------------------------------------------------------------------
+    // TScrollableWindow ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * 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.setX(Math.min(Math.max(0, getWidth() - 17), 17));
+            hScroller.setY(getHeight() - 2);
+            hScroller.setWidth(getWidth() - hScroller.getX() - 3);
+            hScroller.setBigChange(getWidth() - hScroller.getX() - 3);
+        }
+        if (vScroller != null) {
+            vScroller.setX(getWidth() - 2);
+            vScroller.setHeight(getHeight() - 2);
+            vScroller.setBigChange(getHeight() - 2);
+        }
+    }
+
+    /**
+     * Recompute whatever data is displayed by this widget.
+     */
+    public void reflowData() {
+        // Default: nothing to do
+    }
+
+    /**
+     * Get the horizontal scrollbar, or null if this Viewport does not
+     * support horizontal scrolling.
+     *
+     * @return the horizontal scrollbar
+     */
+    public THScroller getHorizontalScroller() {
+        return hScroller;
+    }
+
+    /**
+     * Get the vertical scrollbar, or null if this Viewport does not support
+     * vertical scrolling.
+     *
+     * @return the vertical scrollbar
+     */
+    public TVScroller getVerticalScroller() {
+        return vScroller;
+    }
+
+    /**
+     * Get the value that corresponds to being on the top edge of the
+     * vertical scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getTopValue() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getTopValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the top edge of the
+     * vertical scroll bar.
+     *
+     * @param topValue the new scroll value
+     */
+    public void setTopValue(final int topValue) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setTopValue(topValue);
+        }
+    }
+
+    /**
+     * Get the value that corresponds to being on the bottom edge of the
+     * vertical scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getBottomValue() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getBottomValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the bottom edge of the
+     * vertical scroll bar.
+     *
+     * @param bottomValue the new scroll value
+     */
+    public void setBottomValue(final int bottomValue) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setBottomValue(bottomValue);
+        }
+    }
+
+    /**
+     * Get current value of the vertical scroll.
+     *
+     * @return the scroll value
+     */
+    public int getVerticalValue() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getValue();
+        }
+    }
+
+    /**
+     * Set current value of the vertical scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setVerticalValue(final int value) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setValue(value);
+        }
+    }
+
+    /**
+     * Get the increment for clicking on an arrow on the vertical scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getVerticalSmallChange() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getSmallChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking on an arrow on the vertical scrollbar.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setVerticalSmallChange(final int smallChange) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setSmallChange(smallChange);
+        }
+    }
+
+    /**
+     * Get the increment for clicking in the bar between the box and an
+     * arrow on the vertical scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getVerticalBigChange() {
+        if (vScroller == null) {
+            return 0;
+        } else {
+            return vScroller.getBigChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow on the vertical scrollbar.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setVerticalBigChange(final int bigChange) {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.setBigChange(bigChange);
+        }
+    }
+
+    /**
+     * Perform a small step change up.
+     */
+    public void verticalDecrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.decrement();
+        }
+    }
+
+    /**
+     * Perform a small step change down.
+     */
+    public void verticalIncrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.increment();
+        }
+    }
+
+    /**
+     * Perform a big step change up.
+     */
+    public void bigVerticalDecrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.bigDecrement();
+        }
+    }
+
+    /**
+     * Perform a big step change down.
+     */
+    public void bigVerticalIncrement() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.bigIncrement();
+        }
+    }
+
+    /**
+     * Go to the top edge of the vertical scroller.
+     */
+    public void toTop() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.toTop();
+        }
+    }
+
+    /**
+     * Go to the bottom edge of the vertical scroller.
+     */
+    public void toBottom() {
+        if (vScroller == null) {
+            return;
+        } else {
+            vScroller.toBottom();
+        }
+    }
+
+    /**
+     * Get the value that corresponds to being on the left edge of the
+     * horizontal scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getLeftValue() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getLeftValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the left edge of the
+     * horizontal scroll bar.
+     *
+     * @param leftValue the new scroll value
+     */
+    public void setLeftValue(final int leftValue) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setLeftValue(leftValue);
+        }
+    }
+
+    /**
+     * Get the value that corresponds to being on the right edge of the
+     * horizontal scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getRightValue() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getRightValue();
+        }
+    }
+
+    /**
+     * Set the value that corresponds to being on the right edge of the
+     * horizontal scroll bar.
+     *
+     * @param rightValue the new scroll value
+     */
+    public void setRightValue(final int rightValue) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setRightValue(rightValue);
+        }
+    }
+
+    /**
+     * Get current value of the horizontal scroll.
+     *
+     * @return the scroll value
+     */
+    public int getHorizontalValue() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getValue();
+        }
+    }
+
+    /**
+     * Set current value of the horizontal scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setHorizontalValue(final int value) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setValue(value);
+        }
+    }
+
+    /**
+     * Get the increment for clicking on an arrow on the horizontal
+     * scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getHorizontalSmallChange() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getSmallChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking on an arrow on the horizontal
+     * scrollbar.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setHorizontalSmallChange(final int smallChange) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setSmallChange(smallChange);
+        }
+    }
+
+    /**
+     * Get the increment for clicking in the bar between the box and an
+     * arrow on the horizontal scrollbar.
+     *
+     * @return the increment value
+     */
+    public int getHorizontalBigChange() {
+        if (hScroller == null) {
+            return 0;
+        } else {
+            return hScroller.getBigChange();
+        }
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow on the horizontal scrollbar.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setHorizontalBigChange(final int bigChange) {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.setBigChange(bigChange);
+        }
+    }
+
+    /**
+     * Perform a small step change left.
+     */
+    public void horizontalDecrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.decrement();
+        }
+    }
+
+    /**
+     * Perform a small step change right.
+     */
+    public void horizontalIncrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.increment();
+        }
+    }
+
+    /**
+     * Perform a big step change left.
+     */
+    public void bigHorizontalDecrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.bigDecrement();
+        }
+    }
+
+    /**
+     * Perform a big step change right.
+     */
+    public void bigHorizontalIncrement() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.bigIncrement();
+        }
+    }
+
+    /**
+     * Go to the left edge of the horizontal scroller.
+     */
+    public void toLeft() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.toLeft();
+        }
+    }
+
+    /**
+     * Go to the right edge of the horizontal scroller.
+     */
+    public void toRight() {
+        if (hScroller == null) {
+            return;
+        } else {
+            hScroller.toRight();
+        }
+    }
+
+    /**
+     * Go to the top-left edge of the horizontal and vertical scrollers.
+     */
+    public void toHome() {
+        if (hScroller != null) {
+            hScroller.toLeft();
+        }
+        if (vScroller != null) {
+            vScroller.toTop();
+        }
+    }
+
+    /**
+     * Go to the bottom-right edge of the horizontal and vertical scrollers.
+     */
+    public void toEnd() {
+        if (hScroller != null) {
+            hScroller.toRight();
+        }
+        if (vScroller != null) {
+            vScroller.toBottom();
+        }
+    }
+
+    /**
+     * Check if a mouse press/release/motion event coordinate is over the
+     * vertical scrollbar.
+     *
+     * @param mouse a mouse-based event
+     * @return whether or not the mouse is on the scrollbar
+     */
+    protected final boolean mouseOnVerticalScroller(final TMouseEvent mouse) {
+        if (vScroller == null) {
+            return false;
+        }
+        if ((mouse.getAbsoluteX() == vScroller.getAbsoluteX())
+            && (mouse.getAbsoluteY() >= vScroller.getAbsoluteY())
+            && (mouse.getAbsoluteY() <  vScroller.getAbsoluteY() +
+                vScroller.getHeight())
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Check if a mouse press/release/motion event coordinate is over the
+     * horizontal scrollbar.
+     *
+     * @param mouse a mouse-based event
+     * @return whether or not the mouse is on the scrollbar
+     */
+    protected final boolean mouseOnHorizontalScroller(final TMouseEvent mouse) {
+        if (hScroller == null) {
+            return false;
+        }
+        if ((mouse.getAbsoluteY() == hScroller.getAbsoluteY())
+            && (mouse.getAbsoluteX() >= hScroller.getAbsoluteX())
+            && (mouse.getAbsoluteX() <  hScroller.getAbsoluteX() +
+                hScroller.getWidth())
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+}
diff --git a/src/jexer/TSpinner.java b/src/jexer/TSpinner.java
new file mode 100644 (file)
index 0000000..61fac65
--- /dev/null
@@ -0,0 +1,191 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TSpinner implements a simple up/down spinner.
+ */
+public class TSpinner extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The action to perform when the user clicks on the up arrow.
+     */
+    private TAction upAction = null;
+
+    /**
+     * The action to perform when the user clicks on the down arrow.
+     */
+    private TAction downAction = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param upAction action to call when the up arrow is clicked or pressed
+     * @param downAction action to call when the down arrow is clicked or
+     * pressed
+     */
+    public TSpinner(final TWidget parent, final int x, final int y,
+        final TAction upAction, final TAction downAction) {
+
+        // Set parent and window
+        super(parent, x, y, 2, 1);
+
+        this.upAction = upAction;
+        this.downAction = downAction;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the up arrow.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse is currently on the up arrow
+     */
+    private boolean mouseOnUpArrow(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() == getWidth() - 2)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the down arrow.
+     *
+     * @param mouse mouse event
+     * @return true if the mouse is currently on the down arrow
+     */
+    private boolean mouseOnDownArrow(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() == getWidth() - 1)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse checkbox presses.
+     *
+     * @param mouse mouse button down event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if ((mouseOnUpArrow(mouse)) && (mouse.isMouse1())) {
+            up();
+        } else if ((mouseOnDownArrow(mouse)) && (mouse.isMouse1())) {
+            down();
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbUp)) {
+            up();
+            return;
+        }
+        if (keypress.equals(kbDown)) {
+            down();
+            return;
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the spinner arrows.
+     */
+    @Override
+    public void draw() {
+        CellAttributes spinnerColor;
+
+        if (isAbsoluteActive()) {
+            spinnerColor = getTheme().getColor("tspinner.active");
+        } else {
+            spinnerColor = getTheme().getColor("tspinner.inactive");
+        }
+
+        putCharXY(getWidth() - 2, 0, GraphicsChars.UPARROW, spinnerColor);
+        putCharXY(getWidth() - 1, 0, GraphicsChars.DOWNARROW, spinnerColor);
+    }
+
+    // ------------------------------------------------------------------------
+    // TSpinner ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Perform the "up" action.
+     */
+    private void up() {
+        if (upAction != null) {
+            upAction.DO(this);
+        }
+    }
+
+    /**
+     * Perform the "down" action.
+     */
+    private void down() {
+        if (downAction != null) {
+            downAction.DO(this);
+        }
+    }
+
+}
diff --git a/src/jexer/TSplitPane.java b/src/jexer/TSplitPane.java
new file mode 100644 (file)
index 0000000..b308e9b
--- /dev/null
@@ -0,0 +1,642 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+
+/**
+ * TSplitPane contains two widgets with a draggable horizontal or vertical
+ * bar between them.
+ */
+public class TSplitPane extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, split vertically.  If false, split horizontally.
+     */
+    private boolean vertical = true;
+
+    /**
+     * The location of the split bar, either as a column number for vertical
+     * split or a row number for horizontal split.
+     */
+    private int split = 0;
+
+    /**
+     * The widget on the left side.
+     */
+    private TWidget left;
+
+    /**
+     * The widget on the right side.
+     */
+    private TWidget right;
+
+    /**
+     * The widget on the top side.
+     */
+    private TWidget top;
+
+    /**
+     * The widget on the bottom side.
+     */
+    private TWidget bottom;
+
+    /**
+     * If true, we are in the middle of a split move.
+     */
+    private boolean inSplitMove = false;
+
+    /**
+     * The last seen mouse position.
+     */
+    private TMouseEvent mouse;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     * @param vertical if true, split vertically
+     */
+    public TSplitPane(final TWidget parent, final int x, final int y,
+        final int width, final int height, final boolean vertical) {
+
+        super(parent, x, y, width, height);
+
+        this.vertical = vertical;
+        center();
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize me
+            super.onResize(event);
+
+            if (vertical && (split >= getWidth() - 2)) {
+                center();
+            } else if (!vertical && (split >= getHeight() - 2)) {
+                center();
+            } else {
+                layoutChildren();
+            }
+        }
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        inSplitMove = false;
+
+        if (mouse.isMouse1()) {
+            if (vertical) {
+                inSplitMove = (mouse.getAbsoluteX() - getAbsoluteX() == split);
+            } else {
+                inSplitMove = (mouse.getAbsoluteY() - getAbsoluteY() == split);
+            }
+            if (inSplitMove) {
+                return;
+            }
+        }
+
+        // I didn't take it, pass it on to my children
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if (inSplitMove && mouse.isMouse1()) {
+            // Stop moving split
+            inSplitMove = false;
+            return;
+        }
+
+        // I didn't take it, pass it on to my children
+        super.onMouseUp(mouse);
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if ((mouse.getAbsoluteX() - getAbsoluteX() < 0)
+            || (mouse.getAbsoluteX() - getAbsoluteX() >= getWidth())
+            || (mouse.getAbsoluteY() - getAbsoluteY() < 0)
+            || (mouse.getAbsoluteY() - getAbsoluteY() >= getHeight())
+        ) {
+            // Mouse has travelled out of my window.
+            inSplitMove = false;
+        }
+
+        if (inSplitMove) {
+            if (vertical) {
+                split = mouse.getAbsoluteX() - getAbsoluteX();
+                split = Math.min(Math.max(1, split), getWidth() - 2);
+            } else {
+                split = mouse.getAbsoluteY() - getAbsoluteY();
+                split = Math.min(Math.max(1, split), getHeight() - 2);
+            }
+            layoutChildren();
+            return;
+        }
+
+        // I didn't take it, pass it on to my children
+        super.onMouseMotion(mouse);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw me on screen.
+     */
+    @Override
+    public void draw() {
+        CellAttributes attr = getTheme().getColor("tsplitpane");
+        if (vertical) {
+            vLineXY(split, 0, getHeight(), GraphicsChars.WINDOW_SIDE, attr);
+
+            // Draw intersections of children
+            if ((left instanceof TSplitPane)
+                && (((TSplitPane) left).vertical == false)
+                && (right instanceof TSplitPane)
+                && (((TSplitPane) right).vertical == false)
+                && (((TSplitPane) left).split == ((TSplitPane) right).split)
+            ) {
+                putCharXY(split, ((TSplitPane) left).split, '\u253C', attr);
+            } else {
+                if ((left instanceof TSplitPane)
+                    && (((TSplitPane) left).vertical == false)
+                ) {
+                    putCharXY(split, ((TSplitPane) left).split, '\u2524', attr);
+                }
+                if ((right instanceof TSplitPane)
+                    && (((TSplitPane) right).vertical == false)
+                ) {
+                    putCharXY(split, ((TSplitPane) right).split, '\u251C',
+                        attr);
+                }
+            }
+
+            if ((mouse != null)
+                && (mouse.getAbsoluteX() == getAbsoluteX() + split)
+                && (mouse.getAbsoluteY() >= getAbsoluteY()) &&
+                (mouse.getAbsoluteY() < getAbsoluteY() + getHeight())
+            ) {
+                putCharXY(split, mouse.getAbsoluteY() - getAbsoluteY(),
+                    '\u2194', attr);
+            }
+        } else {
+            hLineXY(0, split, getWidth(), GraphicsChars.SINGLE_BAR, attr);
+
+            // Draw intersections of children
+            if ((top instanceof TSplitPane)
+                && (((TSplitPane) top).vertical == true)
+                && (bottom instanceof TSplitPane)
+                && (((TSplitPane) bottom).vertical == true)
+                && (((TSplitPane) top).split == ((TSplitPane) bottom).split)
+            ) {
+                putCharXY(((TSplitPane) top).split, split, '\u253C', attr);
+            } else {
+                if ((top instanceof TSplitPane)
+                    && (((TSplitPane) top).vertical == true)
+                ) {
+                    putCharXY(((TSplitPane) top).split, split, '\u2534', attr);
+                }
+                if ((bottom instanceof TSplitPane)
+                    && (((TSplitPane) bottom).vertical == true)
+                ) {
+                    putCharXY(((TSplitPane) bottom).split, split, '\u252C',
+                        attr);
+                }
+            }
+
+            if ((mouse != null)
+                && (mouse.getAbsoluteY() == getAbsoluteY() + split)
+                && (mouse.getAbsoluteX() >= getAbsoluteX()) &&
+                (mouse.getAbsoluteX() < getAbsoluteX() + getWidth())
+            ) {
+                putCharXY(mouse.getAbsoluteX() - getAbsoluteX(), split,
+                    '\u2195', attr);
+            }
+        }
+
+    }
+
+    /**
+     * Generate a human-readable string for this widget.
+     *
+     * @return a human-readable string
+     */
+    @Override
+    public String toString() {
+        return String.format("%s(%8x) %s position (%d, %d) geometry %dx%d " +
+            "split %d left %s(%8x) right %s(%8x) top %s(%8x) bottom %s(%8x) " +
+            "active %s enabled %s visible %s", getClass().getName(),
+            hashCode(), (vertical ? "VERTICAL" : "HORIZONTAL"),
+            getX(), getY(), getWidth(), getHeight(), split,
+            (left == null ? "null" : left.getClass().getName()),
+            (left == null ? 0 : left.hashCode()),
+            (right == null ? "null" : right.getClass().getName()),
+            (right == null ? 0 : right.hashCode()),
+            (top == null ? "null" : top.getClass().getName()),
+            (top == null ? 0 : top.hashCode()),
+            (bottom == null ? "null" : bottom.getClass().getName()),
+            (bottom == null ? 0 : bottom.hashCode()),
+            isActive(), isEnabled(), isVisible());
+    }
+
+    // ------------------------------------------------------------------------
+    // TSplitPane -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the widget on the left side.
+     *
+     * @return the widget on the left, or null if not set
+     */
+    public TWidget getLeft() {
+        return left;
+    }
+
+    /**
+     * Set the widget on the left side.
+     *
+     * @param left the widget to set, or null to remove
+     */
+    public void setLeft(final TWidget left) {
+        if (!vertical) {
+            throw new IllegalArgumentException("cannot set left on " +
+                "horizontal split pane");
+        }
+        if (left == null) {
+            if (this.left != null) {
+                remove(this.left);
+            }
+            this.left = null;
+            return;
+        }
+        this.left = left;
+        left.setParent(this, false);
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+    }
+
+    /**
+     * Get the widget on the right side.
+     *
+     * @return the widget on the right, or null if not set
+     */
+    public TWidget getRight() {
+        return right;
+    }
+
+    /**
+     * Set the widget on the right side.
+     *
+     * @param right the widget to set, or null to remove
+     */
+    public void setRight(final TWidget right) {
+        if (!vertical) {
+            throw new IllegalArgumentException("cannot set right on " +
+                "horizontal split pane");
+        }
+        if (right == null) {
+            if (this.right != null) {
+                remove(this.right);
+            }
+            this.right = null;
+            return;
+        }
+        this.right = right;
+        right.setParent(this, false);
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+    }
+
+    /**
+     * Get the widget on the top side.
+     *
+     * @return the widget on the top, or null if not set
+     */
+    public TWidget getTop() {
+        return top;
+    }
+
+    /**
+     * Set the widget on the top side.
+     *
+     * @param top the widget to set, or null to remove
+     */
+    public void setTop(final TWidget top) {
+        if (vertical) {
+            throw new IllegalArgumentException("cannot set top on vertical " +
+                "split pane");
+        }
+        if (top == null) {
+            if (this.top != null) {
+                remove(this.top);
+            }
+            this.top = null;
+            return;
+        }
+        this.top = top;
+        top.setParent(this, false);
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+    }
+
+    /**
+     * Get the widget on the bottom side.
+     *
+     * @return the widget on the bottom, or null if not set
+     */
+    public TWidget getBottom() {
+        return bottom;
+    }
+
+    /**
+     * Set the widget on the bottom side.
+     *
+     * @param bottom the widget to set, or null to remove
+     */
+    public void setBottom(final TWidget bottom) {
+        if (vertical) {
+            throw new IllegalArgumentException("cannot set bottom on " +
+                "vertical split pane");
+        }
+        if (bottom == null) {
+            if (this.bottom != null) {
+                remove(this.bottom);
+            }
+            this.bottom = null;
+            return;
+        }
+        this.bottom = bottom;
+        bottom.setParent(this, false);
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+    }
+
+    /**
+     * Remove a widget, regardless of what pane it is on.
+     *
+     * @param widget the widget to remove
+     */
+    public void removeWidget(final TWidget widget) {
+        if (widget == null) {
+            throw new IllegalArgumentException("cannot remove null widget");
+        }
+        if (left == widget) {
+            left = null;
+            assert(right != widget);
+            assert(top != widget);
+            assert(bottom != widget);
+            return;
+        }
+        if (right == widget) {
+            right = null;
+            assert(left != widget);
+            assert(top != widget);
+            assert(bottom != widget);
+            return;
+        }
+        if (top == widget) {
+            top = null;
+            assert(left != widget);
+            assert(right != widget);
+            assert(bottom != widget);
+            return;
+        }
+        if (bottom == widget) {
+            bottom = null;
+            assert(left != widget);
+            assert(right != widget);
+            assert(top != widget);
+            return;
+        }
+        throw new IllegalArgumentException("widget " + widget +
+            " not in this split");
+    }
+
+    /**
+     * Replace a widget, regardless of what pane it is on, with another
+     * widget.
+     *
+     * @param oldWidget the widget to remove
+     * @param newWidget the widget to replace it with
+     */
+    public void replaceWidget(final TWidget oldWidget,
+        final TWidget newWidget) {
+
+        if (oldWidget == null) {
+            throw new IllegalArgumentException("cannot remove null oldWidget");
+        }
+        if (left == oldWidget) {
+            setLeft(newWidget);
+            assert(right != newWidget);
+            assert(top != newWidget);
+            assert(bottom != newWidget);
+            return;
+        }
+        if (right == oldWidget) {
+            setRight(newWidget);
+            assert(left != newWidget);
+            assert(top != newWidget);
+            assert(bottom != newWidget);
+            return;
+        }
+        if (top == oldWidget) {
+            setTop(newWidget);
+            assert(left != newWidget);
+            assert(right != newWidget);
+            assert(bottom != newWidget);
+            return;
+        }
+        if (bottom == oldWidget) {
+            setBottom(newWidget);
+            assert(left != newWidget);
+            assert(right != newWidget);
+            assert(top != newWidget);
+            return;
+        }
+        throw new IllegalArgumentException("oldWidget " + oldWidget +
+            " not in this split");
+    }
+
+    /**
+     * Layout the two child widgets.
+     */
+    private void layoutChildren() {
+        if (vertical) {
+            if (left != null) {
+                left.setDimensions(0, 0, split, getHeight());
+                left.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        left.getWidth(), left.getHeight()));
+            }
+            if (right != null) {
+                right.setDimensions(split + 1, 0, getWidth() - split - 1,
+                    getHeight());
+                right.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        right.getWidth(), right.getHeight()));
+            }
+        } else {
+            if (top != null) {
+                top.setDimensions(0, 0, getWidth(), split);
+                top.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        top.getWidth(), top.getHeight()));
+            }
+            if (bottom != null) {
+                bottom.setDimensions(0, split + 1, getWidth(),
+                    getHeight() - split - 1);
+                bottom.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        bottom.getWidth(), bottom.getHeight()));
+            }
+        }
+    }
+
+    /**
+     * Recenter the split to the middle of this split pane.
+     */
+    public void center() {
+        if (vertical) {
+            split = getWidth() / 2;
+        } else {
+            split = getHeight() / 2;
+        }
+        layoutChildren();
+    }
+
+    /**
+     * Remove this split, removing the widget specified.
+     *
+     * @param widgetToRemove the widget to remove
+     * @param doClose if true, call the close() method before removing the
+     * child
+     * @return the pane that remains, or null if nothing is retained
+     */
+    public TWidget removeSplit(final TWidget widgetToRemove,
+        final boolean doClose) {
+
+        TWidget keep = null;
+        if (vertical) {
+            if ((widgetToRemove != left) && (widgetToRemove != right)) {
+                throw new IllegalArgumentException("widget to remove is not " +
+                    "either of the panes in this splitpane");
+            }
+            if (widgetToRemove == left) {
+                keep = right;
+            } else {
+                keep = left;
+            }
+
+        } else {
+            if ((widgetToRemove != top) && (widgetToRemove != bottom)) {
+                throw new IllegalArgumentException("widget to remove is not " +
+                    "either of the panes in this splitpane");
+            }
+            if (widgetToRemove == top) {
+                keep = bottom;
+            } else {
+                keep = top;
+            }
+        }
+
+        // Remove me from my parent widget.
+        TWidget myParent = getParent();
+        remove(false);
+
+        if (keep == null) {
+            if (myParent instanceof TSplitPane) {
+                // TSplitPane has a left/right/top/bottom link to me
+                // somewhere, remove it.
+                ((TSplitPane) myParent).removeWidget(this);
+            }
+
+            // Nothing is left of either pane.  Remove me and bail out.
+            return null;
+        }
+
+        if (myParent instanceof TSplitPane) {
+            // TSplitPane has a left/right/top/bottom link to me
+            // somewhere, replace me with keep.
+            ((TSplitPane) myParent).replaceWidget(this, keep);
+        } else {
+            keep.setParent(myParent, false);
+            keep.setDimensions(getX(), getY(), getWidth(), getHeight());
+            keep.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                    getHeight()));
+        }
+
+        return keep;
+    }
+
+}
diff --git a/src/jexer/TStatusBar.java b/src/jexer/TStatusBar.java
new file mode 100644 (file)
index 0000000..fbd79da
--- /dev/null
@@ -0,0 +1,329 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+
+/**
+ * TStatusBar implements a status line with clickable buttons.
+ */
+public class TStatusBar extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Remember mouse state.
+     */
+    private TMouseEvent mouse;
+
+    /**
+     * The text to display on the right side of the shortcut keys.
+     */
+    private String text = null;
+
+    /**
+     * The shortcut keys.
+     */
+    private List<TStatusBarKey> keys = new ArrayList<TStatusBarKey>();
+
+    /**
+     * A single shortcut key.
+     */
+    private class TStatusBarKey {
+
+        /**
+         * The keypress for this action.
+         */
+        public TKeypress key;
+
+        /**
+         * The command to issue.
+         */
+        public TCommand cmd;
+
+        /**
+         * The label text.
+         */
+        public String label;
+
+        /**
+         * If true, the mouse is on this key.
+         */
+        public boolean selected;
+
+        /**
+         * The left edge coordinate to draw this key with.
+         */
+        public int x = 0;
+
+        /**
+         * The width of this key on the screen.
+         *
+         * @return the number of columns this takes when drawn
+         */
+        public int width() {
+            return StringUtils.width(this.label) +
+                StringUtils.width(this.key.toString()) + 3;
+        }
+
+        /**
+         * Add a key to this status bar.
+         *
+         * @param key the key to trigger on
+         * @param cmd the command event to issue when key is pressed or this
+         * item is clicked
+         * @param label the label for this action
+         */
+        public TStatusBarKey(final TKeypress key, final TCommand cmd,
+            final String label) {
+
+            this.key    = key;
+            this.cmd    = cmd;
+            this.label  = label;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param window the window associated with this status bar
+     * @param text text for the bar on the bottom row
+     */
+    public TStatusBar(final TWindow window, final String text) {
+
+        // TStatusBar is a parentless widget, because TApplication handles
+        // its drawing and event routing directly.
+        super(null, false, 0, 0, StringUtils.width(text), 1);
+
+        this.text = text;
+        setWindow(window);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param window the window associated with this status bar
+     */
+    public TStatusBar(final TWindow window) {
+        this(window, "");
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keypresses.
+     *
+     * @param keypress keystroke event
+     * @return true if this keypress was consumed
+     */
+    public boolean statusBarKeypress(final TKeypressEvent keypress) {
+        for (TStatusBarKey key: keys) {
+            if (keypress.equals(key.key)) {
+                getApplication().postMenuEvent(new TCommandEvent(key.cmd));
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the button.
+     *
+     * @param statusBarKey the status bar item
+     * @return if true the mouse is currently on the button
+     */
+    private boolean mouseOnShortcut(final TStatusBarKey statusBarKey) {
+        if ((mouse != null)
+            && (mouse.getAbsoluteY() == getApplication().getDesktopBottom())
+            && (mouse.getAbsoluteX() >= statusBarKey.x)
+            && (mouse.getAbsoluteX() < statusBarKey.x + statusBarKey.width())
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     * @return true if this mouse event was consumed
+     */
+    public boolean statusBarMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        for (TStatusBarKey key: keys) {
+            if ((mouseOnShortcut(key)) && (mouse.isMouse1())) {
+                key.selected = true;
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     * @return true if this mouse event was consumed
+     */
+    public boolean statusBarMouseUp(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        for (TStatusBarKey key: keys) {
+            if (key.selected && mouse.isMouse1()) {
+                key.selected = false;
+
+                // Dispatch the event
+                getApplication().postMenuEvent(new TCommandEvent(key.cmd));
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    public void statusBarMouseMotion(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        for (TStatusBarKey key: keys) {
+            if (!mouseOnShortcut(key)) {
+                key.selected = false;
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the bar.
+     */
+    @Override
+    public void draw() {
+        CellAttributes barColor = new CellAttributes();
+        barColor.setTo(getTheme().getColor("tstatusbar.text"));
+        CellAttributes keyColor = new CellAttributes();
+        keyColor.setTo(getTheme().getColor("tstatusbar.button"));
+        CellAttributes selectedColor = new CellAttributes();
+        selectedColor.setTo(getTheme().getColor("tstatusbar.selected"));
+
+        // Status bar is weird.  Its draw() method is called directly by
+        // TApplication after everything is drawn, and after
+        // Screen.resetClipping().  So at this point we are drawing in
+        // absolute coordinates, not relative to our TWindow.
+        int row = getScreen().getHeight() - 1;
+        int width = getScreen().getWidth();
+
+        hLineXY(0, row, width, ' ', barColor);
+
+        int col = 0;
+        for (TStatusBarKey key: keys) {
+            String keyStr = key.key.toString();
+            if (key.selected) {
+                putCharXY(col++, row, ' ', selectedColor);
+                putStringXY(col, row, keyStr, selectedColor);
+                col += StringUtils.width(keyStr);
+                putCharXY(col++, row, ' ', selectedColor);
+                putStringXY(col, row, key.label, selectedColor);
+                col += StringUtils.width(key.label);
+                putCharXY(col++, row, ' ', selectedColor);
+            } else {
+                putCharXY(col++, row, ' ', barColor);
+                putStringXY(col, row, keyStr, keyColor);
+                col += StringUtils.width(keyStr) + 1;
+                putStringXY(col, row, key.label, barColor);
+                col += StringUtils.width(key.label);
+                putCharXY(col++, row, ' ', barColor);
+            }
+        }
+        if (text.length() > 0) {
+            if (keys.size() > 0) {
+                putCharXY(col++, row, GraphicsChars.VERTICAL_BAR, barColor);
+            }
+            putCharXY(col++, row, ' ', barColor);
+            putStringXY(col, row, text, barColor);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TStatusBar -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Add a key to this status bar.
+     *
+     * @param key the key to trigger on
+     * @param cmd the command event to issue when key is pressed or this item
+     * is clicked
+     * @param label the label for this action
+     */
+    public void addShortcutKeypress(final TKeypress key, final TCommand cmd,
+        final String label) {
+
+        TStatusBarKey newKey = new TStatusBarKey(key, cmd, label);
+        if (keys.size() > 0) {
+            TStatusBarKey oldKey = keys.get(keys.size() - 1);
+            newKey.x = oldKey.x + oldKey.width();
+        }
+        keys.add(newKey);
+    }
+
+    /**
+     * Set the text to display on the right side of the shortcut keys.
+     *
+     * @param text the new text
+     */
+    public void setText(final String text) {
+        this.text = text;
+    }
+
+}
diff --git a/src/jexer/TTableWidget.java b/src/jexer/TTableWidget.java
new file mode 100644 (file)
index 0000000..749b731
--- /dev/null
@@ -0,0 +1,2359 @@
+/*
+ * 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.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TTableWidget is used to display and edit regular two-dimensional tables of
+ * cells.
+ *
+ * This class was inspired by a TTable implementation originally developed by
+ * David "Niki" ROULET [niki@nikiroo.be], made available under MIT at
+ * https://github.com/nikiroo/jexer/tree/ttable_pull.
+ */
+public class TTableWidget extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Available borders for cells.
+     */
+    public enum Border {
+        /**
+         * No border.
+         */
+        NONE,
+
+        /**
+         * Single bar: \u2502 (vertical) and \u2500 (horizontal).
+         */
+        SINGLE,
+
+        /**
+         * Double bar: \u2551 (vertical) and \u2550 (horizontal).
+         */
+        DOUBLE,
+
+        /**
+         * Thick bar: \u2503 (vertical heavy) and \u2501 (horizontal heavy).
+         */
+        THICK,
+    }
+
+    /**
+     * If true, put a grid of numbers in the cells.
+     */
+    private static final boolean DEBUG = false;
+
+    /**
+     * Row label width.
+     */
+    private static final int ROW_LABEL_WIDTH = 8;
+
+    /**
+     * Column label height.
+     */
+    private static final int COLUMN_LABEL_HEIGHT = 1;
+
+    /**
+     * Column default width.
+     */
+    private static final int COLUMN_DEFAULT_WIDTH = 8;
+
+    /**
+     * Extra rows to add.
+     */
+    private static final int EXTRA_ROWS = (DEBUG ? 10 : 0);
+
+    /**
+     * Extra columns to add.
+     */
+    private static final int EXTRA_COLUMNS = (DEBUG ? 3 : 0);
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The underlying data, organized as columns.
+     */
+    private ArrayList<Column> columns = new ArrayList<Column>();
+
+    /**
+     * The underlying data, organized as rows.
+     */
+    private ArrayList<Row> rows = new ArrayList<Row>();
+
+    /**
+     * The row in model corresponding to the top-left visible cell.
+     */
+    private int top = 0;
+
+    /**
+     * The column in model corresponding to the top-left visible cell.
+     */
+    private int left = 0;
+
+    /**
+     * The row in model corresponding to the currently selected cell.
+     */
+    private int selectedRow = 0;
+
+    /**
+     * The column in model corresponding to the currently selected cell.
+     */
+    private int selectedColumn = 0;
+
+    /**
+     * If true, highlight the entire row of the currently-selected cell.
+     */
+    private boolean highlightRow = false;
+
+    /**
+     * If true, highlight the entire column of the currently-selected cell.
+     */
+    private boolean highlightColumn = false;
+
+    /**
+     * If true, show the row labels as the first column.
+     */
+    private boolean showRowLabels = true;
+
+    /**
+     * If true, show the column labels as the first row.
+     */
+    private boolean showColumnLabels = true;
+
+    /**
+     * The top border for the first row.
+     */
+    private Border topBorder = Border.NONE;
+
+    /**
+     * The left border for the first column.
+     */
+    private Border leftBorder = Border.NONE;
+
+    /**
+     * Column represents a column of cells.
+     */
+    public class Column {
+
+        /**
+         * X position of this column.
+         */
+        private int x = 0;
+
+        /**
+         * Width of column.
+         */
+        private int width = COLUMN_DEFAULT_WIDTH;
+
+        /**
+         * The cells of this column.
+         */
+        private ArrayList<Cell> cells = new ArrayList<Cell>();
+
+        /**
+         * Column label.
+         */
+        private String label = "";
+
+        /**
+         * The right border for this column.
+         */
+        private Border rightBorder = Border.NONE;
+
+        /**
+         * Constructor sets label to lettered column.
+         *
+         * @param col column number to use for this column.  Column 0 will be
+         * "A", column 1 will be "B", column 26 will be "AA", and so on.
+         */
+        Column(int col) {
+            label = makeColumnLabel(col);
+        }
+
+        /**
+         * Add an entry to this column.
+         *
+         * @param cell the cell to add
+         */
+        public void add(final Cell cell) {
+            cells.add(cell);
+        }
+
+        /**
+         * Get an entry from this column.
+         *
+         * @param row the entry index to get
+         * @return the cell at row
+         */
+        public Cell get(final int row) {
+            return cells.get(row);
+        }
+
+        /**
+         * Get the X position of the cells in this column.
+         *
+         * @return the position
+         */
+        public int getX() {
+            return x;
+        }
+
+        /**
+         * Set the X position of the cells in this column.
+         *
+         * @param x the position
+         */
+        public void setX(final int x) {
+            for (Cell cell: cells) {
+                cell.setX(x);
+            }
+            this.x = x;
+        }
+
+    }
+
+    /**
+     * Row represents a row of cells.
+     */
+    public class Row {
+
+        /**
+         * Y position of this row.
+         */
+        private int y = 0;
+
+        /**
+         * Height of row.
+         */
+        private int height = 1;
+
+        /**
+         * The cells of this row.
+         */
+        private ArrayList<Cell> cells = new ArrayList<Cell>();
+
+        /**
+         * Row label.
+         */
+        private String label = "";
+
+        /**
+         * The bottom border for this row.
+         */
+        private Border bottomBorder = Border.NONE;
+
+        /**
+         * Constructor sets label to numbered row.
+         *
+         * @param row row number to use for this row
+         */
+        Row(final int row) {
+            label = Integer.toString(row);
+        }
+
+        /**
+         * Add an entry to this column.
+         *
+         * @param cell the cell to add
+         */
+        public void add(final Cell cell) {
+            cells.add(cell);
+        }
+
+        /**
+         * Get an entry from this row.
+         *
+         * @param column the entry index to get
+         * @return the cell at column
+         */
+        public Cell get(final int column) {
+            return cells.get(column);
+        }
+        /**
+         * Get the Y position of the cells in this column.
+         *
+         * @return the position
+         */
+        public int getY() {
+            return y;
+        }
+
+        /**
+         * Set the Y position of the cells in this column.
+         *
+         * @param y the position
+         */
+        public void setY(final int y) {
+            for (Cell cell: cells) {
+                cell.setY(y);
+            }
+            this.y = y;
+        }
+
+    }
+
+    /**
+     * Cell represents an editable cell in the table.  Normally, navigation
+     * to a cell only highlights it; pressing Enter or F2 will switch to
+     * editing mode.
+     */
+    public class Cell extends TWidget {
+
+        // --------------------------------------------------------------------
+        // Variables ----------------------------------------------------------
+        // --------------------------------------------------------------------
+
+        /**
+         * The field containing the cell's data.
+         */
+        private TField field;
+
+        /**
+         * The column of this cell.
+         */
+        private int column;
+
+        /**
+         * The row of this cell.
+         */
+        private int row;
+
+        /**
+         * If true, the cell is being edited.
+         */
+        private boolean isEditing = false;
+
+        /**
+         * If true, the cell is read-only (non-editable).
+         */
+        private boolean readOnly = false;
+
+        /**
+         * Text of field before editing.
+         */
+        private String fieldText;
+
+        // --------------------------------------------------------------------
+        // Constructors -------------------------------------------------------
+        // --------------------------------------------------------------------
+
+        /**
+         * Public constructor.
+         *
+         * @param parent parent widget
+         * @param x column relative to parent
+         * @param y row relative to parent
+         * @param width width of widget
+         * @param height height of widget
+         * @param column column index of this cell
+         * @param row row index of this cell
+         */
+        public Cell(final TTableWidget parent, final int x, final int y,
+            final int width, final int height, final int column,
+            final int row) {
+
+            super(parent, x, y, width, height);
+            this.column = column;
+            this.row = row;
+
+            field = addField(0, 0, width, false);
+            field.setEnabled(false);
+            field.setBackgroundChar(' ');
+        }
+
+        // --------------------------------------------------------------------
+        // Event handlers -----------------------------------------------------
+        // --------------------------------------------------------------------
+
+        /**
+         * Handle mouse double-click events.
+         *
+         * @param mouse mouse double-click event
+         */
+        @Override
+        public void onMouseDoubleClick(final TMouseEvent mouse) {
+            // Use TWidget's code to pass the event to the children.
+            super.onMouseDown(mouse);
+
+            // Double-click means to start editing.
+            fieldText = field.getText();
+            isEditing = true;
+            field.setEnabled(true);
+            activate(field);
+
+            if (isActive()) {
+                // Let the table know that I was activated.
+                ((TTableWidget) getParent()).selectedRow = row;
+                ((TTableWidget) getParent()).selectedColumn = column;
+                ((TTableWidget) getParent()).alignGrid();
+            }
+        }
+
+        /**
+         * Handle mouse press events.
+         *
+         * @param mouse mouse button press event
+         */
+        @Override
+        public void onMouseDown(final TMouseEvent mouse) {
+            // Use TWidget's code to pass the event to the children.
+            super.onMouseDown(mouse);
+
+            if (isActive()) {
+                // Let the table know that I was activated.
+                ((TTableWidget) getParent()).selectedRow = row;
+                ((TTableWidget) getParent()).selectedColumn = column;
+                ((TTableWidget) getParent()).alignGrid();
+            }
+        }
+
+        /**
+         * Handle mouse release events.
+         *
+         * @param mouse mouse button release event
+         */
+        @Override
+        public void onMouseUp(final TMouseEvent mouse) {
+            // Use TWidget's code to pass the event to the children.
+            super.onMouseDown(mouse);
+
+            if (isActive()) {
+                // Let the table know that I was activated.
+                ((TTableWidget) getParent()).selectedRow = row;
+                ((TTableWidget) getParent()).selectedColumn = column;
+                ((TTableWidget) getParent()).alignGrid();
+            }
+        }
+
+        /**
+         * Handle keystrokes.
+         *
+         * @param keypress keystroke event
+         */
+        @Override
+        public void onKeypress(final TKeypressEvent keypress) {
+            // System.err.println("Cell onKeypress: " + keypress);
+
+            if (readOnly) {
+                // Read only: do nothing.
+                return;
+            }
+
+            if (isEditing) {
+                if (keypress.equals(kbEsc)) {
+                    // ESC cancels the edit.
+                    cancelEdit();
+                    return;
+                }
+                if (keypress.equals(kbEnter)) {
+                    // Enter ends editing.
+
+                    // Pass down to field first so that it can execute
+                    // enterAction if specified.
+                    super.onKeypress(keypress);
+
+                    fieldText = field.getText();
+                    isEditing = false;
+                    field.setEnabled(false);
+                    return;
+                }
+                // Pass down to field.
+                super.onKeypress(keypress);
+            }
+
+            if (keypress.equals(kbEnter) || keypress.equals(kbF2)) {
+                // Enter or F2 starts editing.
+                fieldText = field.getText();
+                isEditing = true;
+                field.setEnabled(true);
+                activate(field);
+                return;
+            }
+        }
+
+        // --------------------------------------------------------------------
+        // TWidget ------------------------------------------------------------
+        // --------------------------------------------------------------------
+
+        /**
+         * Draw this cell.
+         */
+        @Override
+        public void draw() {
+            TTableWidget table = (TTableWidget) getParent();
+
+            if (isAbsoluteActive()) {
+                if (isEditing) {
+                    field.setActiveColorKey("tfield.active");
+                    field.setInactiveColorKey("tfield.inactive");
+                } else {
+                    field.setActiveColorKey("ttable.selected");
+                    field.setInactiveColorKey("ttable.selected");
+                }
+            } else if (((table.selectedColumn == column)
+                    && ((table.selectedRow == row)
+                        || (table.highlightColumn == true)))
+                || ((table.selectedRow == row)
+                    && ((table.selectedColumn == column)
+                        || (table.highlightRow == true)))
+            ) {
+                field.setActiveColorKey("ttable.active");
+                field.setInactiveColorKey("ttable.active");
+            } else {
+                field.setActiveColorKey("ttable.active");
+                field.setInactiveColorKey("ttable.inactive");
+            }
+
+            assert (isVisible() == true);
+
+            super.draw();
+        }
+
+        // --------------------------------------------------------------------
+        // TTable.Cell --------------------------------------------------------
+        // --------------------------------------------------------------------
+
+        /**
+         * Get field text.
+         *
+         * @return field text
+         */
+        public final String getText() {
+            return field.getText();
+        }
+
+        /**
+         * Set field text.
+         *
+         * @param text the new field text
+         */
+        public void setText(final String text) {
+            field.setText(text);
+        }
+
+        /**
+         * Cancel any pending edit.
+         */
+        public void cancelEdit() {
+            // Cancel any pending edit.
+            if (fieldText != null) {
+                field.setText(fieldText);
+            }
+            isEditing = false;
+            field.setEnabled(false);
+        }
+
+        /**
+         * Set an entire column of cells read-only (non-editable) or not.
+         *
+         * @param readOnly if true, the cells will be non-editable
+         */
+        public void setReadOnly(final boolean readOnly) {
+            cancelEdit();
+            this.readOnly = readOnly;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     * @param gridColumns number of columns in grid
+     * @param gridRows number of rows in grid
+     */
+    public TTableWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height, final int gridColumns,
+        final int gridRows) {
+
+        super(parent, x, y, width, height);
+
+        /*
+        System.err.println("gridColumns " + gridColumns +
+            " gridRows " + gridRows);
+         */
+
+        if (gridColumns < 1) {
+            throw new IllegalArgumentException("Column count cannot be less " +
+                "than 1");
+        }
+        if (gridRows < 1) {
+            throw new IllegalArgumentException("Row count cannot be less " +
+                "than 1");
+        }
+
+        // Initialize the starting row and column.
+        rows.add(new Row(0));
+        columns.add(new Column(0));
+        assert (rows.get(0).height == 1);
+
+        // Place a grid of cells that fit in this space.
+        for (int row = 0; row < gridRows; row++) {
+            for (int column = 0; column < gridColumns; column++) {
+                Cell cell = new Cell(this, 0, 0, COLUMN_DEFAULT_WIDTH, 1,
+                    column, row);
+
+                if (DEBUG) {
+                    // For debugging: set a grid of cell index labels.
+                    cell.setText("" + row + " " + column);
+                }
+                rows.get(row).add(cell);
+                columns.get(column).add(cell);
+
+                if (columns.size() < gridColumns) {
+                    columns.add(new Column(column + 1));
+                }
+            }
+            if (row < gridRows - 1) {
+                rows.add(new Row(row + 1));
+            }
+        }
+        for (int i = 0; i < rows.size(); i++) {
+            rows.get(i).setY(i + (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0));
+        }
+        for (int j = 0; j < columns.size(); j++) {
+            columns.get(j).setX((j * (COLUMN_DEFAULT_WIDTH + 1)) +
+                (showRowLabels ? ROW_LABEL_WIDTH : 0));
+        }
+        activate(columns.get(selectedColumn).get(selectedRow));
+
+        alignGrid();
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    public TTableWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height) {
+
+        this(parent, x, y, width, height,
+            width / (COLUMN_DEFAULT_WIDTH + 1) + EXTRA_COLUMNS,
+            height + EXTRA_ROWS);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouse.isMouseWheelUp() || mouse.isMouseWheelDown()) {
+            // Treat wheel up/down as 3 up/down
+            TKeypressEvent keyEvent;
+            if (mouse.isMouseWheelUp()) {
+                keyEvent = new TKeypressEvent(kbUp);
+            } else {
+                keyEvent = new TKeypressEvent(kbDown);
+            }
+            for (int i = 0; i < 3; i++) {
+                onKeypress(keyEvent);
+            }
+            return;
+        }
+
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbTab)
+            || keypress.equals(kbShiftTab)
+        ) {
+            // Squash tab and back-tab.  They don't make sense in the TTable
+            // grid context.
+            return;
+        }
+
+        // If editing, pass to that cell and do nothing else.
+        if (getSelectedCell().isEditing) {
+            super.onKeypress(keypress);
+            return;
+        }
+
+        if (keypress.equals(kbLeft)) {
+            // Left
+            if (selectedColumn > 0) {
+                selectedColumn--;
+            }
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbRight)) {
+            // Right
+            if (selectedColumn < columns.size() - 1) {
+                selectedColumn++;
+            }
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbUp)) {
+            // Up
+            if (selectedRow > 0) {
+                selectedRow--;
+            }
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbDown)) {
+            // Down
+            if (selectedRow < rows.size() - 1) {
+                selectedRow++;
+            }
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbHome)) {
+            // Home - leftmost column
+            selectedColumn = 0;
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbEnd)) {
+            // End - rightmost column
+            selectedColumn = columns.size() - 1;
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbPgUp)) {
+            // PgUp - Treat like multiple up
+            for (int i = 0; i < getHeight() - 2; i++) {
+                if (selectedRow > 0) {
+                    selectedRow--;
+                }
+            }
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbPgDn)) {
+            // PgDn - Treat like multiple up
+            for (int i = 0; i < getHeight() - 2; i++) {
+                if (selectedRow < rows.size() - 1) {
+                    selectedRow++;
+                }
+            }
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbCtrlHome)) {
+            // Ctrl-Home - go to top-left
+            selectedRow = 0;
+            selectedColumn = 0;
+            activate(columns.get(selectedColumn).get(selectedRow));
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else if (keypress.equals(kbCtrlEnd)) {
+            // Ctrl-End - go to bottom-right
+            selectedRow = rows.size() - 1;
+            selectedColumn = columns.size() - 1;
+            activate(columns.get(selectedColumn).get(selectedRow));
+            activate(columns.get(selectedColumn).get(selectedRow));
+        } else {
+            // Pass to the Cell.
+            super.onKeypress(keypress);
+        }
+
+        // We may have scrolled off screen.  Reset positions as needed to
+        // make the newly selected cell visible.
+        alignGrid();
+    }
+
+    /**
+     * Handle widget resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        super.onResize(event);
+
+        bottomRightCorner();
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the table row/column labels, and borders.
+     */
+    @Override
+    public void draw() {
+        CellAttributes labelColor = getTheme().getColor("ttable.label");
+        CellAttributes labelColorSelected = getTheme().getColor("ttable.label.selected");
+        CellAttributes borderColor = getTheme().getColor("ttable.border");
+
+        // Column labels.
+        if (showColumnLabels == true) {
+            for (int i = left; i < columns.size(); i++) {
+                if (columns.get(i).get(top).isVisible() == false) {
+                    break;
+                }
+                putStringXY(columns.get(i).get(top).getX(), 0,
+                    String.format(" %-" +
+                        (columns.get(i).width - 2)
+                        + "s ", columns.get(i).label),
+                    (i == selectedColumn ? labelColorSelected : labelColor));
+            }
+        }
+
+        // Row labels.
+        if (showRowLabels == true) {
+            for (int i = top; i < rows.size(); i++) {
+                if (rows.get(i).get(left).isVisible() == false) {
+                    break;
+                }
+                putStringXY(0, rows.get(i).get(left).getY(),
+                    String.format(" %-6s ", rows.get(i).label),
+                    (i == selectedRow ? labelColorSelected : labelColor));
+            }
+        }
+
+        // Draw vertical borders.
+        if (leftBorder == Border.SINGLE) {
+            vLineXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                (topBorder == Border.NONE ? 0 : 1) +
+                    (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                getHeight(), '\u2502', borderColor);
+        }
+        for (int i = left; i < columns.size(); i++) {
+            if (columns.get(i).get(top).isVisible() == false) {
+                break;
+            }
+            if (columns.get(i).rightBorder == Border.SINGLE) {
+                vLineXY(columns.get(i).getX() + columns.get(i).width,
+                    (topBorder == Border.NONE ? 0 : 1) +
+                        (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                    getHeight(), '\u2502', borderColor);
+            }
+        }
+
+        // Draw horizontal borders.
+        if (topBorder == Border.SINGLE) {
+            hLineXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                getWidth(), '\u2500', borderColor);
+        }
+        for (int i = top; i < rows.size(); i++) {
+            if (rows.get(i).get(left).isVisible() == false) {
+                break;
+            }
+            if (rows.get(i).bottomBorder == Border.SINGLE) {
+                hLineXY((leftBorder == Border.NONE ? 0 : 1) +
+                        (showRowLabels ? ROW_LABEL_WIDTH : 0),
+                    rows.get(i).getY() + rows.get(i).height - 1,
+                    getWidth(), '\u2500', borderColor);
+            } else if (rows.get(i).bottomBorder == Border.DOUBLE) {
+                hLineXY((leftBorder == Border.NONE ? 0 : 1) +
+                        (showRowLabels ? ROW_LABEL_WIDTH : 0),
+                    rows.get(i).getY() + rows.get(i).height - 1,
+                    getWidth(), '\u2550', borderColor);
+            } else if (rows.get(i).bottomBorder == Border.THICK) {
+                hLineXY((leftBorder == Border.NONE ? 0 : 1) +
+                        (showRowLabels ? ROW_LABEL_WIDTH : 0),
+                    rows.get(i).getY() + rows.get(i).height - 1,
+                    getWidth(), '\u2501', borderColor);
+            }
+        }
+        // Top-left corner if needed
+        if ((topBorder == Border.SINGLE) && (leftBorder == Border.SINGLE)) {
+            putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                '\u250c', borderColor);
+        }
+
+        // Now draw the correct corners
+        for (int i = top; i < rows.size(); i++) {
+            if (rows.get(i).get(left).isVisible() == false) {
+                break;
+            }
+            for (int j = left; j < columns.size(); j++) {
+                if (columns.get(j).get(i).isVisible() == false) {
+                    break;
+                }
+                if ((i == top) && (topBorder == Border.SINGLE)
+                    && (columns.get(j).rightBorder == Border.SINGLE)
+                ) {
+                    // Top tee
+                    putCharXY(columns.get(j).getX() + columns.get(j).width,
+                        (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                        '\u252c', borderColor);
+                }
+                if ((j == left) && (leftBorder == Border.SINGLE)
+                    && (rows.get(i).bottomBorder == Border.SINGLE)
+                ) {
+                    // Left tee
+                    putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                        rows.get(i).getY() + rows.get(i).height - 1,
+                        '\u251c', borderColor);
+                }
+                if ((columns.get(j).rightBorder == Border.SINGLE)
+                    && (rows.get(i).bottomBorder == Border.SINGLE)
+                ) {
+                    // Intersection of single bars
+                    putCharXY(columns.get(j).getX() + columns.get(j).width,
+                        rows.get(i).getY() + rows.get(i).height - 1,
+                        '\u253c', borderColor);
+                }
+                if ((j == left) && (leftBorder == Border.SINGLE)
+                    && (rows.get(i).bottomBorder == Border.DOUBLE)
+                ) {
+                    // Left tee: single bar vertical, double bar horizontal
+                    putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                        rows.get(i).getY() + rows.get(i).height - 1,
+                        '\u255e', borderColor);
+                }
+                if ((j == left) && (leftBorder == Border.SINGLE)
+                    && (rows.get(i).bottomBorder == Border.THICK)
+                ) {
+                    // Left tee: single bar vertical, thick bar horizontal
+                    putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                        rows.get(i).getY() + rows.get(i).height - 1,
+                        '\u251d', borderColor);
+                }
+                if ((columns.get(j).rightBorder == Border.SINGLE)
+                    && (rows.get(i).bottomBorder == Border.DOUBLE)
+                ) {
+                    // Intersection: single bar vertical, double bar
+                    // horizontal
+                    putCharXY(columns.get(j).getX() + columns.get(j).width,
+                        rows.get(i).getY() + rows.get(i).height - 1,
+                        '\u256a', borderColor);
+                }
+                if ((columns.get(j).rightBorder == Border.SINGLE)
+                    && (rows.get(i).bottomBorder == Border.THICK)
+                ) {
+                    // Intersection: single bar vertical, thick bar
+                    // horizontal
+                    putCharXY(columns.get(j).getX() + columns.get(j).width,
+                        rows.get(i).getY() + rows.get(i).height - 1,
+                        '\u253f', borderColor);
+                }
+            }
+        }
+
+        // Now draw the window borders.
+        super.draw();
+    }
+
+    // ------------------------------------------------------------------------
+    // TTable -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Generate the default letter name for a column number.
+     *
+     * @param col column number to use for this column.  Column 0 will be
+     * "A", column 1 will be "B", column 26 will be "AA", and so on.
+     */
+    private String makeColumnLabel(int col) {
+        StringBuilder sb = new StringBuilder();
+        for (;;) {
+            sb.append((char) ('A' + (col % 26)));
+            if (col < 26) {
+                break;
+            }
+            col /= 26;
+        }
+        return sb.reverse().toString();
+    }
+
+    /**
+     * Get the currently-selected cell.
+     *
+     * @return the selected cell
+     */
+    public Cell getSelectedCell() {
+        assert (rows.get(selectedRow) != null);
+        assert (rows.get(selectedRow).get(selectedColumn) != null);
+        assert (columns.get(selectedColumn) != null);
+        assert (columns.get(selectedColumn).get(selectedRow) != null);
+        assert (rows.get(selectedRow).get(selectedColumn) ==
+            columns.get(selectedColumn).get(selectedRow));
+
+        return (columns.get(selectedColumn).get(selectedRow));
+    }
+
+    /**
+     * Get the currently-selected column.
+     *
+     * @return the selected column
+     */
+    public Column getSelectedColumn() {
+        assert (selectedColumn >= 0);
+        assert (columns.size() > selectedColumn);
+        assert (columns.get(selectedColumn) != null);
+        return columns.get(selectedColumn);
+    }
+
+    /**
+     * Get the currently-selected row.
+     *
+     * @return the selected row
+     */
+    public Row getSelectedRow() {
+        assert (selectedRow >= 0);
+        assert (rows.size() > selectedRow);
+        assert (rows.get(selectedRow) != null);
+        return rows.get(selectedRow);
+    }
+
+    /**
+     * Get the currently-selected column number.  0 is the left-most column.
+     *
+     * @return the selected column number
+     */
+    public int getSelectedColumnNumber() {
+        return selectedColumn;
+    }
+
+    /**
+     * Set the currently-selected column number.  0 is the left-most column.
+     *
+     * @param column the column number to select
+     */
+    public void setSelectedColumnNumber(final int column) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        selectedColumn = column;
+        activate(columns.get(selectedColumn).get(selectedRow));
+        alignGrid();
+    }
+
+    /**
+     * Get the currently-selected row number.  0 is the top-most row.
+     *
+     * @return the selected row number
+     */
+    public int getSelectedRowNumber() {
+        return selectedRow;
+    }
+
+    /**
+     * Set the currently-selected row number.  0 is the left-most column.
+     *
+     * @param row the row number to select
+     */
+    public void setSelectedRowNumber(final int row) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        selectedRow = row;
+        activate(columns.get(selectedColumn).get(selectedRow));
+        alignGrid();
+    }
+
+    /**
+     * Get the highlight row flag.
+     *
+     * @return true if the selected row is highlighted
+     */
+    public boolean getHighlightRow() {
+        return highlightRow;
+    }
+
+    /**
+     * Set the highlight row flag.
+     *
+     * @param highlightRow if true, the selected row will be highlighted
+     */
+    public void setHighlightRow(final boolean highlightRow) {
+        this.highlightRow = highlightRow;
+    }
+
+    /**
+     * Get the highlight column flag.
+     *
+     * @return true if the selected column is highlighted
+     */
+    public boolean getHighlightColumn() {
+        return highlightColumn;
+    }
+
+    /**
+     * Set the highlight column flag.
+     *
+     * @param highlightColumn if true, the selected column will be highlighted
+     */
+    public void setHighlightColumn(final boolean highlightColumn) {
+        this.highlightColumn = highlightColumn;
+    }
+
+    /**
+     * Get the show row labels flag.
+     *
+     * @return true if row labels are shown
+     */
+    public boolean getShowRowLabels() {
+        return showRowLabels;
+    }
+
+    /**
+     * Set the show row labels flag.
+     *
+     * @param showRowLabels if true, the row labels will be shown
+     */
+    public void setShowRowLabels(final boolean showRowLabels) {
+        this.showRowLabels = showRowLabels;
+    }
+
+    /**
+     * Get the show column labels flag.
+     *
+     * @return true if column labels are shown
+     */
+    public boolean getShowColumnLabels() {
+        return showColumnLabels;
+    }
+
+    /**
+     * Set the show column labels flag.
+     *
+     * @param showColumnLabels if true, the column labels will be shown
+     */
+    public void setShowColumnLabels(final boolean showColumnLabels) {
+        this.showColumnLabels = showColumnLabels;
+    }
+
+    /**
+     * Get the number of columns.
+     *
+     * @return the number of columns
+     */
+    public int getColumnCount() {
+        return columns.size();
+    }
+
+    /**
+     * Get the number of rows.
+     *
+     * @return the number of rows
+     */
+    public int getRowCount() {
+        return rows.size();
+    }
+
+
+    /**
+     * Push top and left to the bottom-most right corner of the available
+     * grid.
+     */
+    private void bottomRightCorner() {
+        int viewColumns = getWidth();
+        if (showRowLabels == true) {
+            viewColumns -= ROW_LABEL_WIDTH;
+        }
+
+        // Set left and top such that the table stays on screen if possible.
+        top = rows.size() - getHeight();
+        left = columns.size() - (getWidth() / (viewColumns / (COLUMN_DEFAULT_WIDTH + 1)));
+        // Now ensure the selection is visible.
+        alignGrid();
+    }
+
+    /**
+     * Align the grid so that the selected cell is fully visible.
+     */
+    private void alignGrid() {
+
+        /*
+        System.err.println("alignGrid() # columns " + columns.size() +
+            " # rows " + rows.size());
+         */
+
+        int viewColumns = getWidth();
+        if (showRowLabels == true) {
+            viewColumns -= ROW_LABEL_WIDTH;
+        }
+        if (leftBorder != Border.NONE) {
+            viewColumns--;
+        }
+        int viewRows = getHeight();
+        if (showColumnLabels == true) {
+            viewRows -= COLUMN_LABEL_HEIGHT;
+        }
+        if (topBorder != Border.NONE) {
+            viewRows--;
+        }
+
+        // If we pushed left or right, adjust the box to include the new
+        // selected cell.
+        if (selectedColumn < left) {
+            left = selectedColumn - 1;
+        }
+        if (left < 0) {
+            left = 0;
+        }
+        if (selectedRow < top) {
+            top = selectedRow - 1;
+        }
+        if (top < 0) {
+            top = 0;
+        }
+
+        /*
+         * viewColumns and viewRows now contain the available columns and
+         * rows available to view the selected cell.  We adjust left and top
+         * to ensure the selected cell is within view, and then make all
+         * cells outside the box between (left, top) and (right, bottom)
+         * invisible.
+         *
+         * We need to calculate right and bottom now.
+         */
+        int right = left;
+
+        boolean done = false;
+        while (!done) {
+            int rightCellX = (showRowLabels ? ROW_LABEL_WIDTH : 0);
+            if (leftBorder != Border.NONE) {
+                rightCellX++;
+            }
+            int maxCellX = rightCellX + viewColumns;
+            right = left;
+            boolean selectedIsVisible = false;
+            int selectedX = 0;
+            for (int x = left; x < columns.size(); x++) {
+                if (x == selectedColumn) {
+                    selectedX = rightCellX;
+                    if (selectedX + columns.get(x).width + 1 <= maxCellX) {
+                        selectedIsVisible = true;
+                    }
+                }
+                rightCellX += columns.get(x).width + 1;
+                if (rightCellX >= maxCellX) {
+                    break;
+                }
+                right++;
+            }
+            if (right < selectedColumn) {
+                // selectedColumn is outside the view range.  Push left over,
+                // and calculate again.
+                left++;
+            } else if (left == selectedColumn) {
+                // selectedColumn doesn't fit inside the view range, but we
+                // can't go over any further either.  Bail out.
+                done = true;
+            } else if (selectedIsVisible == false) {
+                // selectedColumn doesn't fit inside the view range, continue
+                // on.
+                left++;
+            } else {
+                // selectedColumn is fully visible, all done.
+                assert (selectedIsVisible == true);
+                done = true;
+            }
+
+        } // while (!done)
+
+        // We have the left/right range correct, set cell visibility and
+        // column X positions.
+        int leftCellX = showRowLabels ? ROW_LABEL_WIDTH : 0;
+        if (leftBorder != Border.NONE) {
+            leftCellX++;
+        }
+        for (int x = 0; x < columns.size(); x++) {
+            if ((x < left) || (x > right)) {
+                for (int i = 0; i < rows.size(); i++) {
+                    columns.get(x).get(i).setVisible(false);
+                    columns.get(x).setX(getWidth() + 1);
+                }
+                continue;
+            }
+            for (int i = 0; i < rows.size(); i++) {
+                columns.get(x).get(i).setVisible(true);
+            }
+            columns.get(x).setX(leftCellX);
+            leftCellX += columns.get(x).width + 1;
+        }
+
+        int bottom = top;
+
+        done = false;
+        while (!done) {
+            int bottomCellY = (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0);
+            if (topBorder != Border.NONE) {
+                bottomCellY++;
+            }
+            int maxCellY = bottomCellY + viewRows;
+            bottom = top;
+            for (int y = top; y < rows.size(); y++) {
+                bottomCellY += rows.get(y).height;
+                if (bottomCellY >= maxCellY) {
+                    break;
+                }
+                bottom++;
+            }
+            if (bottom < selectedRow) {
+                // selectedRow is outside the view range.  Push top down, and
+                // calculate again.
+                top++;
+            } else {
+                // selectedRow is inside the view range, done.
+                done = true;
+            }
+        } // while (!done)
+
+        // We have the top/bottom range correct, set cell visibility and
+        // row Y positions.
+        int topCellY = showColumnLabels ? COLUMN_LABEL_HEIGHT : 0;
+        if (topBorder != Border.NONE) {
+            topCellY++;
+        }
+        for (int y = 0; y < rows.size(); y++) {
+            if ((y < top) || (y > bottom)) {
+                for (int i = 0; i < columns.size(); i++) {
+                    rows.get(y).get(i).setVisible(false);
+                }
+                rows.get(y).setY(getHeight() + 1);
+                continue;
+            }
+            for (int i = 0; i < columns.size(); i++) {
+                rows.get(y).get(i).setVisible(true);
+            }
+            rows.get(y).setY(topCellY);
+            topCellY += rows.get(y).height;
+        }
+
+        // Last thing: cancel any edits that are not the selected cell.
+        for (int y = 0; y < rows.size(); y++) {
+            for (int x = 0; x < columns.size(); x++) {
+                if ((x == selectedColumn) && (y == selectedRow)) {
+                    continue;
+                }
+                rows.get(y).get(x).cancelEdit();
+            }
+        }
+    }
+
+    /**
+     * Load contents from file in CSV format.
+     *
+     * @param csvFile a File referencing the CSV data
+     * @throws IOException if a java.io operation throws
+     */
+    public void loadCsvFile(final File csvFile) throws IOException {
+        BufferedReader reader = null;
+
+        try {
+            reader = new BufferedReader(new FileReader(csvFile));
+
+            String line = null;
+            boolean first = true;
+            for (line = reader.readLine(); line != null;
+                 line = reader.readLine()) {
+
+                List<String> list = StringUtils.fromCsv(line);
+                if (list.size() == 0) {
+                    continue;
+                }
+
+                if (list.size() > columns.size()) {
+                    int n = list.size() - columns.size();
+                    for (int i = 0; i < n; i++) {
+                        selectedColumn = columns.size() - 1;
+                        insertColumnRight(selectedColumn);
+                    }
+                }
+                assert (list.size() == columns.size());
+
+                if (first) {
+                    // First row: just replace what is here.
+                    selectedRow = 0;
+                    first = false;
+                } else {
+                    // All other rows: append to the end.
+                    selectedRow = rows.size() - 1;
+                    insertRowBelow(selectedRow);
+                    selectedRow = rows.size() - 1;
+                }
+                for (int i = 0; i < list.size(); i++) {
+                    rows.get(selectedRow).get(i).setText(list.get(i));
+                }
+            }
+        } finally {
+            if (reader != null) {
+                reader.close();
+            }
+        }
+
+        left = 0;
+        top = 0;
+        selectedRow = 0;
+        selectedColumn = 0;
+        alignGrid();
+        activate(columns.get(selectedColumn).get(selectedRow));
+    }
+
+    /**
+     * Save contents to file in CSV format.
+     *
+     * @param filename file to save to
+     * @throws IOException if a java.io operation throws
+     */
+    public void saveToCsvFilename(final String filename) throws IOException {
+        BufferedWriter writer = null;
+
+        try {
+            writer = new BufferedWriter(new FileWriter(filename));
+            for (Row row: rows) {
+                List<String> list = new ArrayList<String>(row.cells.size());
+                for (Cell cell: row.cells) {
+                    list.add(cell.getText());
+                }
+                writer.write(StringUtils.toCsv(list));
+                writer.write("\n");
+            }
+        } finally {
+            if (writer != null) {
+                writer.close();
+            }
+        }
+    }
+
+    /**
+     * Save contents to file in text format with lines.
+     *
+     * @param filename file to save to
+     * @throws IOException if a java.io operation throws
+     */
+    public void saveToTextFilename(final String filename) throws IOException {
+        BufferedWriter writer = null;
+
+        try {
+            writer = new BufferedWriter(new FileWriter(filename));
+
+            if ((topBorder == Border.SINGLE) && (leftBorder == Border.SINGLE)) {
+                // Emit top-left corner.
+                writer.write("\u250c");
+            }
+
+            if (topBorder == Border.SINGLE) {
+                int cellI = 0;
+                for (Cell cell: rows.get(0).cells) {
+                    for (int i = 0; i < columns.get(cellI).width; i++) {
+                        writer.write("\u2500");
+                    }
+
+                    if (columns.get(cellI).rightBorder == Border.SINGLE) {
+                        if (cellI < columns.size() - 1) {
+                            // Emit top tee.
+                            writer.write("\u252c");
+                        } else {
+                            // Emit top-right corner.
+                            writer.write("\u2510");
+                        }
+                    }
+                    cellI++;
+                }
+            }
+            writer.write("\n");
+
+            int rowI = 0;
+            for (Row row: rows) {
+
+                if (leftBorder == Border.SINGLE) {
+                    // Emit left border.
+                    writer.write("\u2502");
+                }
+
+                int cellI = 0;
+                for (Cell cell: row.cells) {
+                    writer.write(String.format("%" +
+                            columns.get(cellI).width + "s", cell.getText()));
+
+                    if (columns.get(cellI).rightBorder == Border.SINGLE) {
+                        // Emit right border.
+                        writer.write("\u2502");
+                    }
+                    cellI++;
+                }
+                writer.write("\n");
+
+                if (row.bottomBorder == Border.NONE) {
+                    // All done, move on to the next row.
+                    continue;
+                }
+
+                // Emit the bottom borders and intersections.
+                if ((leftBorder == Border.SINGLE)
+                    && (row.bottomBorder != Border.NONE)
+                ) {
+                    if (rowI < rows.size() - 1) {
+                        if (row.bottomBorder == Border.SINGLE) {
+                            // Emit left tee.
+                            writer.write("\u251c");
+                        } else if (row.bottomBorder == Border.DOUBLE) {
+                            // Emit left tee (double).
+                            writer.write("\u255e");
+                        } else if (row.bottomBorder == Border.THICK) {
+                            // Emit left tee (thick).
+                            writer.write("\u251d");
+                        }
+                    }
+
+                    if (rowI == rows.size() - 1) {
+                        if (row.bottomBorder == Border.SINGLE) {
+                            // Emit left bottom corner.
+                            writer.write("\u2514");
+                        } else if (row.bottomBorder == Border.DOUBLE) {
+                            // Emit left bottom corner (double).
+                            writer.write("\u2558");
+                        } else if (row.bottomBorder == Border.THICK) {
+                            // Emit left bottom corner (thick).
+                            writer.write("\u2515");
+                        }
+                    }
+                }
+
+                cellI = 0;
+                for (Cell cell: row.cells) {
+
+                    for (int i = 0; i < columns.get(cellI).width; i++) {
+                        if (row.bottomBorder == Border.SINGLE) {
+                            writer.write("\u2500");
+                        }
+                        if (row.bottomBorder == Border.DOUBLE) {
+                            writer.write("\u2550");
+                        }
+                        if (row.bottomBorder == Border.THICK) {
+                            writer.write("\u2501");
+                        }
+                    }
+
+                    if ((rowI < rows.size() - 1)
+                        && (cellI == columns.size() - 1)
+                        && (row.bottomBorder == Border.SINGLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit right tee.
+                        writer.write("\u2524");
+                    }
+                    if ((rowI < rows.size() - 1)
+                        && (cellI == columns.size() - 1)
+                        && (row.bottomBorder == Border.DOUBLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit right tee (double).
+                        writer.write("\u2561");
+                    }
+                    if ((rowI < rows.size() - 1)
+                        && (cellI == columns.size() - 1)
+                        && (row.bottomBorder == Border.THICK)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit right tee (thick).
+                        writer.write("\u2525");
+                    }
+                    if ((rowI == rows.size() - 1)
+                        && (cellI == columns.size() - 1)
+                        && (row.bottomBorder == Border.SINGLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit right bottom corner.
+                        writer.write("\u2518");
+                    }
+                    if ((rowI == rows.size() - 1)
+                        && (cellI == columns.size() - 1)
+                        && (row.bottomBorder == Border.DOUBLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit right bottom corner (double).
+                        writer.write("\u255b");
+                    }
+                    if ((rowI == rows.size() - 1)
+                        && (cellI == columns.size() - 1)
+                        && (row.bottomBorder == Border.THICK)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit right bottom corner (thick).
+                        writer.write("\u2519");
+                    }
+                    if ((rowI < rows.size() - 1)
+                        && (cellI < columns.size() - 1)
+                        && (row.bottomBorder == Border.SINGLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit intersection.
+                        writer.write("\u253c");
+                    }
+                    if ((rowI < rows.size() - 1)
+                        && (cellI < columns.size() - 1)
+                        && (row.bottomBorder == Border.DOUBLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit intersection (double).
+                        writer.write("\u256a");
+                    }
+                    if ((rowI < rows.size() - 1)
+                        && (cellI < columns.size() - 1)
+                        && (row.bottomBorder == Border.THICK)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit intersection (thick).
+                        writer.write("\u253f");
+                    }
+                    if ((rowI == rows.size() - 1)
+                        && (cellI < columns.size() - 1)
+                        && (row.bottomBorder == Border.SINGLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit bottom tee.
+                        writer.write("\u2534");
+                    }
+                    if ((rowI == rows.size() - 1)
+                        && (cellI < columns.size() - 1)
+                        && (row.bottomBorder == Border.DOUBLE)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit bottom tee (double).
+                        writer.write("\u2567");
+                    }
+                    if ((rowI == rows.size() - 1)
+                        && (cellI < columns.size() - 1)
+                        && (row.bottomBorder == Border.THICK)
+                        && (columns.get(cellI).rightBorder == Border.SINGLE)
+                    ) {
+                        // Emit bottom tee (thick).
+                        writer.write("\u2537");
+                    }
+
+                    cellI++;
+                }
+
+                writer.write("\n");
+                rowI++;
+            }
+        } finally {
+            if (writer != null) {
+                writer.close();
+            }
+        }
+    }
+
+    /**
+     * Set the selected cell location.
+     *
+     * @param column the selected cell location column
+     * @param row the selected cell location row
+     */
+    public void setSelectedCell(final int column, final int row) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        selectedColumn = column;
+        selectedRow = row;
+        alignGrid();
+    }
+
+    /**
+     * Get a particular cell.
+     *
+     * @param column the cell column
+     * @param row the cell row
+     * @return the cell
+     */
+    public Cell getCell(final int column, final int row) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        return rows.get(row).get(column);
+    }
+
+    /**
+     * Get the text of a particular cell.
+     *
+     * @param column the cell column
+     * @param row the cell row
+     * @return the text in the cell
+     */
+    public String getCellText(final int column, final int row) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        return rows.get(row).get(column).getText();
+    }
+
+    /**
+     * Set the text of a particular cell.
+     *
+     * @param column the cell column
+     * @param row the cell row
+     * @param text the text to put into the cell
+     */
+    public void setCellText(final int column, final int row,
+        final String text) {
+
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        rows.get(row).get(column).setText(text);
+    }
+
+    /**
+     * Set the action to perform when the user presses enter on a particular
+     * cell.
+     *
+     * @param column the cell column
+     * @param row the cell row
+     * @param action the action to perform when the user presses enter on the
+     * cell
+     */
+    public void setCellEnterAction(final int column, final int row,
+        final TAction action) {
+
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        rows.get(row).get(column).field.setEnterAction(action);
+    }
+
+    /**
+     * Set the action to perform when the user updates a particular cell.
+     *
+     * @param column the cell column
+     * @param row the cell row
+     * @param action the action to perform when the user updates the cell
+     */
+    public void setCellUpdateAction(final int column, final int row,
+        final TAction action) {
+
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        rows.get(row).get(column).field.setUpdateAction(action);
+    }
+
+    /**
+     * Get the width of a column.
+     *
+     * @param column the column number
+     * @return the width of the column
+     */
+    public int getColumnWidth(final int column) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        return columns.get(column).width;
+    }
+
+    /**
+     * Set the width of a column.
+     *
+     * @param column the column number
+     * @param width the new width of the column
+     */
+    public void setColumnWidth(final int column, final int width) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+
+        if (width < 4) {
+            // Columns may not be smaller than 4 cells wide.
+            return;
+        }
+
+        int delta = width - columns.get(column).width;
+        columns.get(column).width = width;
+        for (Cell cell: columns.get(column).cells) {
+            cell.setWidth(columns.get(column).width);
+            cell.field.setWidth(columns.get(column).width);
+        }
+        for (int i = column + 1; i < columns.size(); i++) {
+            columns.get(i).setX(columns.get(i).getX() + delta);
+        }
+        if (column == columns.size() - 1) {
+            bottomRightCorner();
+        } else {
+            alignGrid();
+        }
+    }
+
+    /**
+     * Get the label of a column.
+     *
+     * @param column the column number
+     * @return the label of the column
+     */
+    public String getColumnLabel(final int column) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        return columns.get(column).label;
+    }
+
+    /**
+     * Set the label of a column.
+     *
+     * @param column the column number
+     * @param label the new label of the column
+     */
+    public void setColumnLabel(final int column, final String label) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        columns.get(column).label = label;
+    }
+
+    /**
+     * Get the label of a row.
+     *
+     * @param row the row number
+     * @return the label of the row
+     */
+    public String getRowLabel(final int row) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        return rows.get(row).label;
+    }
+
+    /**
+     * Set the label of a row.
+     *
+     * @param row the row number
+     * @param label the new label of the row
+     */
+    public void setRowLabel(final int row, final String label) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        rows.get(row).label = label;
+    }
+
+    /**
+     * Insert one row at a particular index.
+     *
+     * @param idx the row number
+     */
+    private void insertRowAt(final int idx) {
+        Row newRow = new Row(idx);
+        for (int i = 0; i < columns.size(); i++) {
+            Cell cell = new Cell(this, columns.get(i).getX(),
+                rows.get(idx).getY(), COLUMN_DEFAULT_WIDTH, 1, i, idx);
+            newRow.add(cell);
+            columns.get(i).cells.add(idx, cell);
+        }
+        rows.add(idx, newRow);
+
+        for (int x = 0; x < columns.size(); x++) {
+            for (int y = idx; y < rows.size(); y++) {
+                columns.get(x).get(y).row = y;
+                columns.get(x).get(y).column = x;
+            }
+        }
+        for (int i = idx + 1; i < rows.size(); i++) {
+            String oldRowLabel = Integer.toString(i - 1);
+            if (rows.get(i).label.equals(oldRowLabel)) {
+                rows.get(i).label = Integer.toString(i);
+            }
+        }
+        alignGrid();
+    }
+
+    /**
+     * Insert one row above a particular row.
+     *
+     * @param row the row number
+     */
+    public void insertRowAbove(final int row) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        insertRowAt(row);
+        selectedRow++;
+        activate(columns.get(selectedColumn).get(selectedRow));
+    }
+
+    /**
+     * Insert one row below a particular row.
+     *
+     * @param row the row number
+     */
+    public void insertRowBelow(final int row) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        int idx = row + 1;
+        if (idx < rows.size()) {
+            insertRowAt(idx);
+            activate(columns.get(selectedColumn).get(selectedRow));
+            return;
+        }
+
+        // row is the last row, we need to perform an append.
+        Row newRow = new Row(idx);
+        for (int i = 0; i < columns.size(); i++) {
+            Cell cell = new Cell(this, columns.get(i).getX(),
+                rows.get(row).getY(), COLUMN_DEFAULT_WIDTH, 1, i, idx);
+            newRow.add(cell);
+            columns.get(i).cells.add(cell);
+        }
+        rows.add(newRow);
+        alignGrid();
+        activate(columns.get(selectedColumn).get(selectedRow));
+    }
+
+    /**
+     * Delete a particular row.
+     *
+     * @param row the row number
+     */
+    public void deleteRow(final int row) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        if (rows.size() == 1) {
+            // Don't delete the last row.
+            return;
+        }
+        for (int i = 0; i < columns.size(); i++) {
+            Cell cell = columns.get(i).cells.remove(row);
+            getChildren().remove(cell);
+        }
+        rows.remove(row);
+
+        for (int x = 0; x < columns.size(); x++) {
+            for (int y = row; y < rows.size(); y++) {
+                columns.get(x).get(y).row = y;
+                columns.get(x).get(y).column = x;
+            }
+        }
+        for (int i = row; i < rows.size(); i++) {
+            String oldRowLabel = Integer.toString(i + 1);
+            if (rows.get(i).label.equals(oldRowLabel)) {
+                rows.get(i).label = Integer.toString(i);
+            }
+        }
+        if (selectedRow == rows.size()) {
+            selectedRow--;
+        }
+        activate(columns.get(selectedColumn).get(selectedRow));
+        bottomRightCorner();
+    }
+
+    /**
+     * Insert one column at a particular index.
+     *
+     * @param idx the column number
+     */
+    private void insertColumnAt(final int idx) {
+        Column newColumn = new Column(idx);
+        for (int i = 0; i < rows.size(); i++) {
+            Cell cell = new Cell(this, columns.get(idx).getX(),
+                rows.get(i).getY(), COLUMN_DEFAULT_WIDTH, 1, idx, i);
+            newColumn.add(cell);
+            rows.get(i).cells.add(idx, cell);
+        }
+        columns.add(idx, newColumn);
+
+        for (int x = idx; x < columns.size(); x++) {
+            for (int y = 0; y < rows.size(); y++) {
+                columns.get(x).get(y).row = y;
+                columns.get(x).get(y).column = x;
+            }
+        }
+        for (int i = idx + 1; i < columns.size(); i++) {
+            String oldColumnLabel = makeColumnLabel(i - 1);
+            if (columns.get(i).label.equals(oldColumnLabel)) {
+                columns.get(i).label = makeColumnLabel(i);
+            }
+        }
+        alignGrid();
+    }
+
+    /**
+     * Insert one column to the left of a particular column.
+     *
+     * @param column the column number
+     */
+    public void insertColumnLeft(final int column) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        insertColumnAt(column);
+        selectedColumn++;
+        activate(columns.get(selectedColumn).get(selectedRow));
+    }
+
+    /**
+     * Insert one column to the right of a particular column.
+     *
+     * @param column the column number
+     */
+    public void insertColumnRight(final int column) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        int idx = column + 1;
+        if (idx < columns.size()) {
+            insertColumnAt(idx);
+            activate(columns.get(selectedColumn).get(selectedRow));
+            return;
+        }
+
+        // column is the last column, we need to perform an append.
+        Column newColumn = new Column(idx);
+        for (int i = 0; i < rows.size(); i++) {
+            Cell cell = new Cell(this, columns.get(column).getX(),
+                rows.get(i).getY(), COLUMN_DEFAULT_WIDTH, 1, idx, i);
+            newColumn.add(cell);
+            rows.get(i).cells.add(cell);
+        }
+        columns.add(newColumn);
+        alignGrid();
+        activate(columns.get(selectedColumn).get(selectedRow));
+    }
+
+    /**
+     * Delete a particular column.
+     *
+     * @param column the column number
+     */
+    public void deleteColumn(final int column) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if (columns.size() == 1) {
+            // Don't delete the last column.
+            return;
+        }
+        for (int i = 0; i < rows.size(); i++) {
+            Cell cell = rows.get(i).cells.remove(column);
+            getChildren().remove(cell);
+        }
+        columns.remove(column);
+
+        for (int x = column; x < columns.size(); x++) {
+            for (int y = 0; y < rows.size(); y++) {
+                columns.get(x).get(y).row = y;
+                columns.get(x).get(y).column = x;
+            }
+        }
+        for (int i = column; i < columns.size(); i++) {
+            String oldColumnLabel = makeColumnLabel(i + 1);
+            if (columns.get(i).label.equals(oldColumnLabel)) {
+                columns.get(i).label = makeColumnLabel(i);
+            }
+        }
+        if (selectedColumn == columns.size()) {
+            selectedColumn--;
+        }
+        activate(columns.get(selectedColumn).get(selectedRow));
+        bottomRightCorner();
+    }
+
+    /**
+     * Delete the selected cell, shifting cells over to the left.
+     */
+    public void deleteCellShiftLeft() {
+        // All we do is copy the text from every cell in this row over.
+        for (int i = selectedColumn + 1; i < columns.size(); i++) {
+            setCellText(i - 1, selectedRow, getCellText(i, selectedRow));
+        }
+        setCellText(columns.size() - 1, selectedRow, "");
+    }
+
+    /**
+     * Delete the selected cell, shifting cells from below up.
+     */
+    public void deleteCellShiftUp() {
+        // All we do is copy the text from every cell in this column up.
+        for (int i = selectedRow + 1; i < rows.size(); i++) {
+            setCellText(selectedColumn, i - 1, getCellText(selectedColumn, i));
+        }
+        setCellText(selectedColumn, rows.size() - 1, "");
+    }
+
+    /**
+     * Set a particular cell read-only (non-editable) or not.
+     *
+     * @param column the cell column
+     * @param row the cell row
+     * @param readOnly if true, the cell will be non-editable
+     */
+    public void setCellReadOnly(final int column, final int row,
+        final boolean readOnly) {
+
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        rows.get(row).get(column).setReadOnly(readOnly);
+    }
+
+    /**
+     * Set an entire row of cells read-only (non-editable) or not.
+     *
+     * @param row the row number
+     * @param readOnly if true, the cells will be non-editable
+     */
+    public void setRowReadOnly(final int row, final boolean readOnly) {
+        if ((row < 0) || (row > rows.size() - 1)) {
+            throw new IndexOutOfBoundsException("Row count is " +
+                rows.size() + ", requested index " + row);
+        }
+        for (Cell cell: rows.get(row).cells) {
+            cell.setReadOnly(readOnly);
+        }
+    }
+
+    /**
+     * Set an entire column of cells read-only (non-editable) or not.
+     *
+     * @param column the column number
+     * @param readOnly if true, the cells will be non-editable
+     */
+    public void setColumnReadOnly(final int column, final boolean readOnly) {
+        if ((column < 0) || (column > columns.size() - 1)) {
+            throw new IndexOutOfBoundsException("Column count is " +
+                columns.size() + ", requested index " + column);
+        }
+        for (Cell cell: columns.get(column).cells) {
+            cell.setReadOnly(readOnly);
+        }
+    }
+
+    /**
+     * Set all borders across the entire table to Border.NONE.
+     */
+    public void setBorderAllNone() {
+        topBorder = Border.NONE;
+        leftBorder = Border.NONE;
+        for (int i = 0; i < columns.size(); i++) {
+            columns.get(i).rightBorder = Border.NONE;
+        }
+        for (int i = 0; i < rows.size(); i++) {
+            rows.get(i).bottomBorder = Border.NONE;
+            rows.get(i).height = 1;
+        }
+        bottomRightCorner();
+    }
+
+    /**
+     * Set all borders across the entire table to Border.SINGLE.
+     */
+    public void setBorderAllSingle() {
+        topBorder = Border.SINGLE;
+        leftBorder = Border.SINGLE;
+        for (int i = 0; i < columns.size(); i++) {
+            columns.get(i).rightBorder = Border.SINGLE;
+        }
+        for (int i = 0; i < rows.size(); i++) {
+            rows.get(i).bottomBorder = Border.SINGLE;
+            rows.get(i).height = 2;
+        }
+        alignGrid();
+    }
+
+    /**
+     * Set all borders around the selected cell to Border.NONE.
+     */
+    public void setBorderCellNone() {
+        if (selectedRow == 0) {
+            topBorder = Border.NONE;
+        }
+        if (selectedColumn == 0) {
+            leftBorder = Border.NONE;
+        }
+        if (selectedColumn > 0) {
+            columns.get(selectedColumn - 1).rightBorder = Border.NONE;
+        }
+        columns.get(selectedColumn).rightBorder = Border.NONE;
+        if (selectedRow > 0) {
+            rows.get(selectedRow - 1).bottomBorder = Border.NONE;
+            rows.get(selectedRow - 1).height = 1;
+        }
+        rows.get(selectedRow).bottomBorder = Border.NONE;
+        rows.get(selectedRow).height = 1;
+        bottomRightCorner();
+    }
+
+    /**
+     * Set all borders around the selected cell to Border.SINGLE.
+     */
+    public void setBorderCellSingle() {
+        if (selectedRow == 0) {
+            topBorder = Border.SINGLE;
+        }
+        if (selectedColumn == 0) {
+            leftBorder = Border.SINGLE;
+        }
+        if (selectedColumn > 0) {
+            columns.get(selectedColumn - 1).rightBorder = Border.SINGLE;
+        }
+        columns.get(selectedColumn).rightBorder = Border.SINGLE;
+        if (selectedRow > 0) {
+            rows.get(selectedRow - 1).bottomBorder = Border.SINGLE;
+            rows.get(selectedRow - 1).height = 2;
+        }
+        rows.get(selectedRow).bottomBorder = Border.SINGLE;
+        rows.get(selectedRow).height = 2;
+        alignGrid();
+    }
+
+    /**
+     * Set the column border to the right of the selected cell to
+     * Border.SINGLE.
+     */
+    public void setBorderColumnRightSingle() {
+        columns.get(selectedColumn).rightBorder = Border.SINGLE;
+        alignGrid();
+    }
+
+    /**
+     * Set the column border to the right of the selected cell to
+     * Border.SINGLE.
+     */
+    public void setBorderColumnLeftSingle() {
+        if (selectedColumn == 0) {
+            leftBorder = Border.SINGLE;
+        } else {
+            columns.get(selectedColumn - 1).rightBorder = Border.SINGLE;
+        }
+        alignGrid();
+    }
+
+    /**
+     * Set the row border above the selected cell to Border.SINGLE.
+     */
+    public void setBorderRowAboveSingle() {
+        if (selectedRow == 0) {
+            topBorder = Border.SINGLE;
+        } else {
+            rows.get(selectedRow - 1).bottomBorder = Border.SINGLE;
+            rows.get(selectedRow - 1).height = 2;
+        }
+        alignGrid();
+    }
+
+    /**
+     * Set the row border below the selected cell to Border.SINGLE.
+     */
+    public void setBorderRowBelowSingle() {
+        rows.get(selectedRow).bottomBorder = Border.SINGLE;
+        rows.get(selectedRow).height = 2;
+        alignGrid();
+    }
+
+    /**
+     * Set the row border below the selected cell to Border.DOUBLE.
+     */
+    public void setBorderRowBelowDouble() {
+        rows.get(selectedRow).bottomBorder = Border.DOUBLE;
+        rows.get(selectedRow).height = 2;
+        alignGrid();
+    }
+
+    /**
+     * Set the row border below the selected cell to Border.THICK.
+     */
+    public void setBorderRowBelowThick() {
+        rows.get(selectedRow).bottomBorder = Border.THICK;
+        rows.get(selectedRow).height = 2;
+        alignGrid();
+    }
+
+}
diff --git a/src/jexer/TTableWindow.java b/src/jexer/TTableWindow.java
new file mode 100644 (file)
index 0000000..766ceaf
--- /dev/null
@@ -0,0 +1,570 @@
+/*
+ * 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.io.File;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ResourceBundle;
+
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.menu.TMenu;
+import jexer.menu.TMenuItem;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TTableWindow is used to display and edit regular two-dimensional tables of
+ * cells.
+ */
+public class TTableWindow extends TScrollableWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TTableWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The table widget.
+     */
+    private TTableWidget tableField;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor sets window title.
+     *
+     * @param parent the main application
+     * @param title the window title
+     */
+    public TTableWindow(final TApplication parent, final String title) {
+
+        super(parent, title, 0, 0, parent.getScreen().getWidth() / 2,
+            parent.getScreen().getHeight() / 2 - 2, RESIZABLE | CENTERED);
+
+        tableField = addTable(0, 0, getWidth() - 2, getHeight() - 2);
+        setupAfterTable();
+    }
+
+    /**
+     * Public constructor loads a grid from a RFC4180 CSV file.
+     *
+     * @param parent the main application
+     * @param csvFile a File referencing the CSV data
+     * @throws IOException if a java.io operation throws
+     */
+    public TTableWindow(final TApplication parent,
+        final File csvFile) throws IOException {
+
+        super(parent, csvFile.getName(), 0, 0,
+            parent.getScreen().getWidth() / 2,
+            parent.getScreen().getHeight() / 2 - 2,
+            RESIZABLE | CENTERED);
+
+        tableField = addTable(0, 0, getWidth() - 2, getHeight() - 2, 1, 1);
+        setupAfterTable();
+        tableField.loadCsvFile(csvFile);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Called by application.switchWindow() when this window gets the
+     * focus, and also by application.addWindow().
+     */
+    public void onFocus() {
+        // Enable the table menu items.
+        getApplication().enableMenuItem(TMenu.MID_TABLE_RENAME_COLUMN);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_RENAME_ROW);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_ROW_LABELS);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_NONE);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_ALL);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_CELL_NONE);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_CELL_ALL);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_RIGHT);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_LEFT);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_TOP);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_BOTTOM);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_THICK_BOTTOM);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_LEFT);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_UP);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_ROW);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_COLUMN);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_LEFT);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_RIGHT);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_ABOVE);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_BELOW);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_COLUMN_NARROW);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_COLUMN_WIDEN);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_FILE_OPEN_CSV);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_FILE_SAVE_CSV);
+        getApplication().enableMenuItem(TMenu.MID_TABLE_FILE_SAVE_TEXT);
+
+        if (tableField != null) {
+
+            // Set the menu to match the flags.
+            TMenuItem menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_ROW_LABELS);
+            if (menuItem != null) {
+                menuItem.setChecked(tableField.getShowRowLabels());
+            }
+            menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS);
+            if (menuItem != null) {
+                menuItem.setChecked(tableField.getShowColumnLabels());
+            }
+            menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW);
+            if (menuItem != null) {
+                menuItem.setChecked(tableField.getHighlightRow());
+            }
+            menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN);
+            if (menuItem != null) {
+                menuItem.setChecked(tableField.getHighlightColumn());
+            }
+        }
+    }
+
+    /**
+     * Called by application.switchWindow() when another window gets the
+     * focus.
+     */
+    public void onUnfocus() {
+        // Disable the table menu items.
+        getApplication().disableMenuItem(TMenu.MID_TABLE_RENAME_COLUMN);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_RENAME_ROW);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_ROW_LABELS);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_NONE);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_ALL);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_CELL_NONE);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_CELL_ALL);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_RIGHT);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_LEFT);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_TOP);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_BOTTOM);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_THICK_BOTTOM);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_LEFT);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_UP);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_ROW);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_COLUMN);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_LEFT);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_RIGHT);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_ABOVE);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_BELOW);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_COLUMN_NARROW);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_COLUMN_WIDEN);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_FILE_OPEN_CSV);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_FILE_SAVE_CSV);
+        getApplication().disableMenuItem(TMenu.MID_TABLE_FILE_SAVE_TEXT);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseDown(mouse);
+
+        if (mouseOnTable(mouse)) {
+            // The table might have changed, update the scollbars.
+            setBottomValue(tableField.getRowCount() - 1);
+            setVerticalValue(tableField.getSelectedRowNumber());
+            setRightValue(tableField.getColumnCount() - 1);
+            setHorizontalValue(tableField.getSelectedColumnNumber());
+        }
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseUp(mouse);
+
+        if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+            // Clicked/dragged on vertical scrollbar.
+            tableField.setSelectedRowNumber(getVerticalValue());
+        }
+        if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+            // Clicked/dragged on horizontal scrollbar.
+            tableField.setSelectedColumnNumber(getHorizontalValue());
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        // Use TWidget's code to pass the event to the children.
+        super.onMouseMotion(mouse);
+
+        if (mouseOnTable(mouse) && mouse.isMouse1()) {
+            // The table might have changed, update the scollbars.
+            setBottomValue(tableField.getRowCount() - 1);
+            setVerticalValue(tableField.getSelectedRowNumber());
+            setRightValue(tableField.getColumnCount() - 1);
+            setHorizontalValue(tableField.getSelectedColumnNumber());
+        } else {
+            if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+                // Clicked/dragged on vertical scrollbar.
+                tableField.setSelectedRowNumber(getVerticalValue());
+            }
+            if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+                // Clicked/dragged on horizontal scrollbar.
+                tableField.setSelectedColumnNumber(getHorizontalValue());
+            }
+        }
+
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        // Use TWidget's code to pass the event to the children.
+        super.onKeypress(keypress);
+
+        // The table might have changed, update the scollbars.
+        setBottomValue(tableField.getRowCount() - 1);
+        setVerticalValue(tableField.getSelectedRowNumber());
+        setRightValue(tableField.getColumnCount() - 1);
+        setHorizontalValue(tableField.getSelectedColumnNumber());
+    }
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the table
+            TResizeEvent tableSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                event.getWidth() - 2, event.getHeight() - 2);
+            tableField.onResize(tableSize);
+
+            // Have TScrollableWindow handle the scrollbars
+            super.onResize(event);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(event);
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (command.equals(cmOpen)) {
+            try {
+                String filename = fileOpenBox(".");
+                if (filename != null) {
+                    try {
+                        new TTableWindow(getApplication(), new File(filename));
+                    } catch (IOException e) {
+                        messageBox(i18n.getString("errorDialogTitle"),
+                            MessageFormat.format(i18n.
+                                getString("errorReadingFile"), e.getMessage()));
+                    }
+                }
+            } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorOpeningFileDialog"), e.getMessage()));
+            }
+            return;
+        }
+
+        if (command.equals(cmSave)) {
+            try {
+                String filename = fileSaveBox(".");
+                if (filename != null) {
+                    tableField.saveToCsvFilename(filename);
+                }
+            } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorWritingFile"), e.getMessage()));
+            }
+            return;
+        }
+
+        // Didn't handle it, let children get it instead
+        super.onCommand(command);
+    }
+
+    /**
+     * Handle posted menu events.
+     *
+     * @param menu menu event
+     */
+    @Override
+    public void onMenu(final TMenuEvent menu) {
+        TInputBox inputBox = null;
+        String filename = null;
+
+        switch (menu.getId()) {
+        case TMenu.MID_TABLE_RENAME_COLUMN:
+            inputBox = inputBox(i18n.getString("renameColumnInputTitle"),
+                i18n.getString("renameColumnInputCaption"),
+                tableField.getColumnLabel(tableField.getSelectedColumnNumber()),
+                TMessageBox.Type.OKCANCEL);
+            if (inputBox.isOk()) {
+                tableField.setColumnLabel(tableField.getSelectedColumnNumber(),
+                    inputBox.getText());
+            }
+            return;
+        case TMenu.MID_TABLE_RENAME_ROW:
+            inputBox = inputBox(i18n.getString("renameRowInputTitle"),
+                i18n.getString("renameRowInputCaption"),
+                tableField.getRowLabel(tableField.getSelectedRowNumber()),
+                TMessageBox.Type.OKCANCEL);
+            if (inputBox.isOk()) {
+                tableField.setRowLabel(tableField.getSelectedRowNumber(),
+                    inputBox.getText());
+            }
+            return;
+        case TMenu.MID_TABLE_VIEW_ROW_LABELS:
+            tableField.setShowRowLabels(getApplication().getMenuItem(
+                menu.getId()).getChecked());
+            return;
+        case TMenu.MID_TABLE_VIEW_COLUMN_LABELS:
+            tableField.setShowColumnLabels(getApplication().getMenuItem(
+                menu.getId()).getChecked());
+            return;
+        case TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW:
+            tableField.setHighlightRow(getApplication().getMenuItem(
+                menu.getId()).getChecked());
+            return;
+        case TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN:
+            tableField.setHighlightColumn(getApplication().getMenuItem(
+                menu.getId()).getChecked());
+            return;
+        case TMenu.MID_TABLE_BORDER_NONE:
+            tableField.setBorderAllNone();
+            return;
+        case TMenu.MID_TABLE_BORDER_ALL:
+            tableField.setBorderAllSingle();
+            return;
+        case TMenu.MID_TABLE_BORDER_CELL_NONE:
+            tableField.setBorderCellNone();
+            return;
+        case TMenu.MID_TABLE_BORDER_CELL_ALL:
+            tableField.setBorderCellSingle();
+            return;
+        case TMenu.MID_TABLE_BORDER_RIGHT:
+            tableField.setBorderColumnRightSingle();
+            return;
+        case TMenu.MID_TABLE_BORDER_LEFT:
+            tableField.setBorderColumnLeftSingle();
+            return;
+        case TMenu.MID_TABLE_BORDER_TOP:
+            tableField.setBorderRowAboveSingle();
+            return;
+        case TMenu.MID_TABLE_BORDER_BOTTOM:
+            tableField.setBorderRowBelowSingle();
+            return;
+        case TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM:
+            tableField.setBorderRowBelowDouble();
+            return;
+        case TMenu.MID_TABLE_BORDER_THICK_BOTTOM:
+            tableField.setBorderRowBelowThick();
+            return;
+        case TMenu.MID_TABLE_DELETE_LEFT:
+            tableField.deleteCellShiftLeft();
+            return;
+        case TMenu.MID_TABLE_DELETE_UP:
+            tableField.deleteCellShiftUp();
+            return;
+        case TMenu.MID_TABLE_DELETE_ROW:
+            tableField.deleteRow(tableField.getSelectedRowNumber());
+            return;
+        case TMenu.MID_TABLE_DELETE_COLUMN:
+            tableField.deleteColumn(tableField.getSelectedColumnNumber());
+            return;
+        case TMenu.MID_TABLE_INSERT_LEFT:
+            tableField.insertColumnLeft(tableField.getSelectedColumnNumber());
+            return;
+        case TMenu.MID_TABLE_INSERT_RIGHT:
+            tableField.insertColumnRight(tableField.getSelectedColumnNumber());
+            return;
+        case TMenu.MID_TABLE_INSERT_ABOVE:
+            tableField.insertRowAbove(tableField.getSelectedColumnNumber());
+            return;
+        case TMenu.MID_TABLE_INSERT_BELOW:
+            tableField.insertRowBelow(tableField.getSelectedColumnNumber());
+            return;
+        case TMenu.MID_TABLE_COLUMN_NARROW:
+            tableField.setColumnWidth(tableField.getSelectedColumnNumber(),
+                tableField.getColumnWidth(tableField.getSelectedColumnNumber()) - 1);
+            return;
+        case TMenu.MID_TABLE_COLUMN_WIDEN:
+            tableField.setColumnWidth(tableField.getSelectedColumnNumber(),
+                tableField.getColumnWidth(tableField.getSelectedColumnNumber()) + 1);
+            return;
+        case TMenu.MID_TABLE_FILE_OPEN_CSV:
+            try {
+                filename = fileOpenBox(".");
+                if (filename != null) {
+                    try {
+                        new TTableWindow(getApplication(), new File(filename));
+                    } catch (IOException e) {
+                        messageBox(i18n.getString("errorDialogTitle"),
+                            MessageFormat.format(i18n.
+                                getString("errorReadingFile"), e.getMessage()));
+                    }
+                }
+            } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorOpeningFileDialog"), e.getMessage()));
+            }
+            return;
+        case TMenu.MID_TABLE_FILE_SAVE_CSV:
+            try {
+                filename = fileSaveBox(".");
+                if (filename != null) {
+                    tableField.saveToCsvFilename(filename);
+                }
+            } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorWritingFile"), e.getMessage()));
+            }
+            return;
+        case TMenu.MID_TABLE_FILE_SAVE_TEXT:
+            try {
+                filename = fileSaveBox(".");
+                if (filename != null) {
+                    tableField.saveToTextFilename(filename);
+                }
+            } catch (IOException e) {
+                messageBox(i18n.getString("errorDialogTitle"),
+                    MessageFormat.format(i18n.
+                        getString("errorWritingFile"), e.getMessage()));
+            }
+            return;
+        default:
+            break;
+        }
+
+        super.onMenu(menu);
+    }
+
+    // ------------------------------------------------------------------------
+    // TTableWindow -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Setup other fields after the table is created.
+     */
+    private void setupAfterTable() {
+        hScroller = new THScroller(this, 17, getHeight() - 2, getWidth() - 20);
+        vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+        setMinimumWindowWidth(25);
+        setMinimumWindowHeight(10);
+        setTopValue(tableField.getSelectedRowNumber());
+        setBottomValue(tableField.getRowCount() - 1);
+        setLeftValue(tableField.getSelectedColumnNumber());
+        setRightValue(tableField.getColumnCount() - 1);
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+
+        statusBar.addShortcutKeypress(kbF2, cmSave,
+            i18n.getString("statusBarSave"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmMenu,
+            i18n.getString("statusBarMenu"));
+
+        // Synchronize the menu with tableField's flags.
+        onFocus();
+    }
+
+    /**
+     * Check if a mouse press/release/motion event coordinate is over the
+     * table.
+     *
+     * @param mouse a mouse-based event
+     * @return whether or not the mouse is on the table
+     */
+    private boolean mouseOnTable(final TMouseEvent mouse) {
+        if ((mouse.getAbsoluteX() >= getAbsoluteX() + 1)
+            && (mouse.getAbsoluteX() <  getAbsoluteX() + getWidth() - 1)
+            && (mouse.getAbsoluteY() >= getAbsoluteY() + 1)
+            && (mouse.getAbsoluteY() <  getAbsoluteY() + getHeight() - 1)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+}
diff --git a/src/jexer/TTableWindow.properties b/src/jexer/TTableWindow.properties
new file mode 100644 (file)
index 0000000..c2c8765
--- /dev/null
@@ -0,0 +1,15 @@
+statusBar=Editor
+statusBarHelp=Help
+statusBarSave=Save CSV
+statusBarOpen=Open CSV
+statusBarMenu=Menu
+
+renameRowInputTitle=Rename Row
+renameRowInputCaption=New row name?
+renameColumnInputTitle=Rename Column
+renameColumnInputCaption=New column name?
+
+errorDialogTitle=Error
+errorReadingFile=Error reading file: {0}
+errorOpeningFileDialog=Error opening file dialog: {0}
+errorSavingFile=Error saving file: {0}
diff --git a/src/jexer/TTerminalWidget.java b/src/jexer/TTerminalWidget.java
new file mode 100644 (file)
index 0000000..bf51e6b
--- /dev/null
@@ -0,0 +1,1326 @@
+/*
+ * 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.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.text.MessageFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import jexer.backend.ECMA48Terminal;
+import jexer.backend.GlyphMaker;
+import jexer.backend.SwingTerminal;
+import jexer.bits.Cell;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.menu.TMenu;
+import jexer.tterminal.DisplayLine;
+import jexer.tterminal.DisplayListener;
+import jexer.tterminal.ECMA48;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TTerminalWidget exposes a ECMA-48 / ANSI X3.64 style terminal in a widget.
+ */
+public class TTerminalWidget extends TScrollableWidget
+                             implements DisplayListener, EditMenuUser {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TTerminalWidget.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The emulator.
+     */
+    private ECMA48 emulator;
+
+    /**
+     * The Process created by the shell spawning constructor.
+     */
+    private Process shell;
+
+    /**
+     * If true, something called 'ptypipe' is on the PATH and executable.
+     */
+    private static boolean ptypipeOnPath = false;
+
+    /**
+     * If true, we are using the ptypipe utility to support dynamic window
+     * resizing.  ptypipe is available at
+     * https://gitlab.com/klamonte/ptypipe .
+     */
+    private boolean ptypipe = false;
+
+    /**
+     * Double-height font.
+     */
+    private GlyphMaker doubleFont;
+
+    /**
+     * Last text width value.
+     */
+    private int lastTextWidth = -1;
+
+    /**
+     * Last text height value.
+     */
+    private int lastTextHeight = -1;
+
+    /**
+     * The blink state, used only by ECMA48 backend and when double-width
+     * chars must be drawn.
+     */
+    private boolean blinkState = true;
+
+    /**
+     * Timer flag, used only by ECMA48 backend and when double-width chars
+     * must be drawn.
+     */
+    private boolean haveTimer = false;
+
+    /**
+     * The last seen visible display.
+     */
+    private List<DisplayLine> display;
+
+    /**
+     * If true, the display has changed and needs updating.
+     */
+    private volatile boolean dirty = true;
+
+    /**
+     * Time that the display was last updated.
+     */
+    private long lastUpdateTime = 0;
+
+    /**
+     * If true, hide the mouse after typing a keystroke.
+     */
+    private boolean hideMouseWhenTyping = true;
+
+    /**
+     * If true, the mouse should not be displayed because a keystroke was
+     * typed.
+     */
+    private boolean typingHidMouse = false;
+
+    /**
+     * The return value from the emulator.
+     */
+    private int exitValue = -1;
+
+    /**
+     * Title to expose to a window.
+     */
+    private String title = "";
+
+    /**
+     * Action to perform when the terminal exits.
+     */
+    private TAction closeAction = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Static constructor.
+     */
+    static {
+        checkForPtypipe();
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param commandLine the command line to execute
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y,
+        final String commandLine) {
+
+        this(parent, x, y, commandLine.split("\\s+"));
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param command the command line to execute
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y,
+        final String [] command) {
+
+        this(parent, x, y, command, null);
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param command the command line to execute
+     * @param closeAction action to perform when the shell exits
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y,
+        final String [] command, final TAction closeAction) {
+
+        this(parent, x, y, 80, 24, command, closeAction);
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     * @param command the command line to execute
+     * @param closeAction action to perform when the shell exits
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height, final String [] command,
+        final TAction closeAction) {
+
+        super(parent, x, y, width, height);
+
+        this.closeAction = closeAction;
+
+        String [] fullCommand;
+
+        // Spawn a shell and pass its I/O to the other constructor.
+        if ((System.getProperty("jexer.TTerminal.ptypipe") != null)
+            && (System.getProperty("jexer.TTerminal.ptypipe").
+                equals("true"))
+        ) {
+            ptypipe = true;
+            fullCommand = new String[command.length + 1];
+            fullCommand[0] = "ptypipe";
+            System.arraycopy(command, 0, fullCommand, 1, command.length);
+        } else if (System.getProperty("jexer.TTerminal.ptypipe",
+                "auto").equals("auto")
+            && (ptypipeOnPath == true)
+        ) {
+            ptypipe = true;
+            fullCommand = new String[command.length + 1];
+            fullCommand[0] = "ptypipe";
+            System.arraycopy(command, 0, fullCommand, 1, command.length);
+        } else if (System.getProperty("os.name").startsWith("Windows")) {
+            fullCommand = new String[3];
+            fullCommand[0] = "cmd";
+            fullCommand[1] = "/c";
+            fullCommand[2] = stringArrayToString(command);
+        } else if (System.getProperty("os.name").startsWith("Mac")) {
+            fullCommand = new String[6];
+            fullCommand[0] = "script";
+            fullCommand[1] = "-q";
+            fullCommand[2] = "-F";
+            fullCommand[3] = "/dev/null";
+            fullCommand[4] = "-c";
+            fullCommand[5] = stringArrayToString(command);
+        } else {
+            // Default: behave like Linux
+            if (System.getProperty("jexer.TTerminal.setsid",
+                    "true").equals("false")
+            ) {
+                fullCommand = new String[5];
+                fullCommand[0] = "script";
+                fullCommand[1] = "-fqe";
+                fullCommand[2] = "/dev/null";
+                fullCommand[3] = "-c";
+                fullCommand[4] = stringArrayToString(command);
+            } else {
+                fullCommand = new String[6];
+                fullCommand[0] = "setsid";
+                fullCommand[1] = "script";
+                fullCommand[2] = "-fqe";
+                fullCommand[3] = "/dev/null";
+                fullCommand[4] = "-c";
+                fullCommand[5] = stringArrayToString(command);
+            }
+        }
+        spawnShell(fullCommand);
+    }
+
+    /**
+     * Public constructor spawns a shell.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y) {
+        this(parent, x, y, (TAction) null);
+    }
+
+    /**
+     * Public constructor spawns a shell.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param closeAction action to perform when the shell exits
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y,
+        final TAction closeAction) {
+
+        this(parent, x, y, 80, 24, closeAction);
+    }
+
+    /**
+     * Public constructor spawns a shell.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     * @param closeAction action to perform when the shell exits
+     */
+    public TTerminalWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height, final TAction closeAction) {
+
+        super(parent, x, y, width, height);
+
+        this.closeAction = closeAction;
+
+        if (System.getProperty("jexer.TTerminal.shell") != null) {
+            String shell = System.getProperty("jexer.TTerminal.shell");
+            if (shell.trim().startsWith("ptypipe")) {
+                ptypipe = true;
+            }
+            spawnShell(shell.split("\\s+"));
+            return;
+        }
+
+        String cmdShellWindows = "cmd.exe";
+
+        // You cannot run a login shell in a bare Process interactively, due
+        // to libc's behavior of buffering when stdin/stdout aren't a tty.
+        // Use 'script' instead to run a shell in a pty.  And because BSD and
+        // GNU differ on the '-f' vs '-F' flags, we need two different
+        // commands.  Lovely.
+        String cmdShellGNU = "script -fqe /dev/null";
+        String cmdShellGNUSetsid = "setsid script -fqe /dev/null";
+        String cmdShellBSD = "script -q -F /dev/null";
+
+        // ptypipe is another solution that permits dynamic window resizing.
+        String cmdShellPtypipe = "ptypipe /bin/bash --login";
+
+        // Spawn a shell and pass its I/O to the other constructor.
+        if ((System.getProperty("jexer.TTerminal.ptypipe") != null)
+            && (System.getProperty("jexer.TTerminal.ptypipe").
+                equals("true"))
+        ) {
+            ptypipe = true;
+            spawnShell(cmdShellPtypipe.split("\\s+"));
+        } else if (System.getProperty("jexer.TTerminal.ptypipe",
+                "auto").equals("auto")
+            && (ptypipeOnPath == true)
+        ) {
+            ptypipe = true;
+            spawnShell(cmdShellPtypipe.split("\\s+"));
+        } else if (System.getProperty("os.name").startsWith("Windows")) {
+            spawnShell(cmdShellWindows.split("\\s+"));
+        } else if (System.getProperty("os.name").startsWith("Mac")) {
+            spawnShell(cmdShellBSD.split("\\s+"));
+        } else if (System.getProperty("os.name").startsWith("Linux")) {
+            if (System.getProperty("jexer.TTerminal.setsid",
+                    "true").equals("false")
+            ) {
+                spawnShell(cmdShellGNU.split("\\s+"));
+            } else {
+                spawnShell(cmdShellGNUSetsid.split("\\s+"));
+            }
+        } else {
+            // When all else fails, assume GNU.
+            spawnShell(cmdShellGNU.split("\\s+"));
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        // Let TWidget set my size.
+        super.onResize(resize);
+
+        if (emulator == null) {
+            return;
+        }
+
+        // Synchronize against the emulator so we don't stomp on its reader
+        // thread.
+        synchronized (emulator) {
+
+            if (resize.getType() == TResizeEvent.Type.WIDGET) {
+                // Resize the scroll bars
+                reflowData();
+                placeScrollbars();
+
+                // Get out of scrollback
+                setVerticalValue(0);
+
+                if (ptypipe) {
+                    emulator.setWidth(getWidth());
+                    emulator.setHeight(getHeight());
+
+                    emulator.writeRemote("\033[8;" + getHeight() + ";" +
+                        getWidth() + "t");
+                }
+
+                // Pass the correct text cell width/height to the emulator
+                if (getScreen() != null) {
+                    emulator.setTextWidth(getScreen().getTextWidth());
+                    emulator.setTextHeight(getScreen().getTextHeight());
+                }
+            }
+            return;
+
+        } // synchronized (emulator)
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (hideMouseWhenTyping) {
+            typingHidMouse = true;
+        }
+
+        // Scrollback up/down
+        if (keypress.equals(kbShiftPgUp)
+            || keypress.equals(kbCtrlPgUp)
+            || keypress.equals(kbAltPgUp)
+        ) {
+            bigVerticalDecrement();
+            dirty = true;
+            return;
+        }
+        if (keypress.equals(kbShiftPgDn)
+            || keypress.equals(kbCtrlPgDn)
+            || keypress.equals(kbAltPgDn)
+        ) {
+            bigVerticalIncrement();
+            dirty = true;
+            return;
+        }
+
+        if ((emulator != null) && (emulator.isReading())) {
+            // Get out of scrollback
+            setVerticalValue(0);
+            emulator.addUserEvent(keypress);
+
+            // UGLY HACK TIME!  cmd.exe needs CRLF, not just CR, so if
+            // this is kBEnter then also send kbCtrlJ.
+            if (keypress.equals(kbEnter)) {
+                if (System.getProperty("os.name").startsWith("Windows")
+                    && (System.getProperty("jexer.TTerminal.cmdHack",
+                            "true").equals("true"))
+                ) {
+                    emulator.addUserEvent(new TKeypressEvent(kbCtrlJ));
+                }
+            }
+
+            readEmulatorState();
+            return;
+        }
+
+        // Process is closed, honor "normal" TUI keystrokes
+        super.onKeypress(keypress);
+    }
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (hideMouseWhenTyping) {
+            typingHidMouse = false;
+        }
+
+        if (emulator != null) {
+            // If the emulator is tracking mouse buttons, it needs to see
+            // wheel events.
+            if (emulator.getMouseProtocol() == ECMA48.MouseProtocol.OFF) {
+                if (mouse.isMouseWheelUp()) {
+                    verticalDecrement();
+                    dirty = true;
+                    return;
+                }
+                if (mouse.isMouseWheelDown()) {
+                    verticalIncrement();
+                    dirty = true;
+                    return;
+                }
+            }
+            if (mouseOnEmulator(mouse)) {
+                emulator.addUserEvent(mouse);
+                readEmulatorState();
+                return;
+            }
+        }
+
+        // Emulator didn't consume it, pass it on
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        if (hideMouseWhenTyping) {
+            typingHidMouse = false;
+        }
+
+        if ((emulator != null) && (mouseOnEmulator(mouse))) {
+            emulator.addUserEvent(mouse);
+            readEmulatorState();
+            return;
+        }
+
+        // Emulator didn't consume it, pass it on
+        super.onMouseUp(mouse);
+    }
+
+    /**
+     * Handle mouse motion events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        if (hideMouseWhenTyping) {
+            typingHidMouse = false;
+        }
+
+        if ((emulator != null) && (mouseOnEmulator(mouse))) {
+            emulator.addUserEvent(mouse);
+            readEmulatorState();
+            return;
+        }
+
+        // Emulator didn't consume it, pass it on
+        super.onMouseMotion(mouse);
+    }
+
+    /**
+     * Handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (emulator == null) {
+            return;
+        }
+
+        if (command.equals(cmPaste)) {
+            // Paste text from clipboard.
+            String text = getClipboard().pasteText();
+            if (text != null) {
+                for (int i = 0; i < text.length(); ) {
+                    int ch = text.codePointAt(i);
+                    emulator.addUserEvent(new TKeypressEvent(false, 0, ch,
+                            false, false, false));
+                    i += Character.charCount(ch);
+                }
+            }
+            return;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TScrollableWidget ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the display buffer.
+     */
+    @Override
+    public void draw() {
+        if (emulator == null) {
+            return;
+        }
+
+        int width = getDisplayWidth();
+
+        boolean syncEmulator = false;
+        if (System.currentTimeMillis() - lastUpdateTime >= 50) {
+            // Too much time has passed, draw it all.
+            syncEmulator = true;
+        } else if (emulator.isReading() && (dirty == false)) {
+            // Wait until the emulator has brought more data in.
+            syncEmulator = false;
+        } else if (!emulator.isReading() && (dirty == true)) {
+            // The emulator won't receive more data, update the display.
+            syncEmulator = true;
+        }
+
+        if ((syncEmulator == true)
+            || (display == null)
+        ) {
+            // We want to minimize the amount of time we have the emulator
+            // locked.  Grab a copy of its display.
+            synchronized (emulator) {
+                // Update the scroll bars
+                reflowData();
+
+                if (!isDrawable()) {
+                    // We lost the connection, onShellExit() called an action
+                    // that ultimately removed this widget from the UI
+                    // hierarchy, so no one cares if we update the display.
+                    // Bail out.
+                    return;
+                }
+
+                if ((display == null) || emulator.isReading()) {
+                    display = emulator.getVisibleDisplay(getHeight(),
+                        -getVerticalValue());
+                    assert (display.size() == getHeight());
+                }
+                width = emulator.getWidth();
+            }
+            dirty = false;
+        }
+
+        // Now draw the emulator screen
+        int row = 0;
+        for (DisplayLine line: display) {
+            int widthMax = width;
+            if (line.isDoubleWidth()) {
+                widthMax /= 2;
+            }
+            if (widthMax > getWidth()) {
+                widthMax = getWidth();
+            }
+            for (int i = 0; i < widthMax; i++) {
+                Cell ch = line.charAt(i);
+
+                if (ch.isImage()) {
+                    putCharXY(i, row, ch);
+                    continue;
+                }
+
+                Cell newCell = new Cell(ch);
+                boolean reverse = line.isReverseColor() ^ ch.isReverse();
+                newCell.setReverse(false);
+                if (reverse) {
+                    if (ch.getForeColorRGB() < 0) {
+                        newCell.setBackColor(ch.getForeColor());
+                        newCell.setBackColorRGB(-1);
+                    } else {
+                        newCell.setBackColorRGB(ch.getForeColorRGB());
+                    }
+                    if (ch.getBackColorRGB() < 0) {
+                        newCell.setForeColor(ch.getBackColor());
+                        newCell.setForeColorRGB(-1);
+                    } else {
+                        newCell.setForeColorRGB(ch.getBackColorRGB());
+                    }
+                }
+                if (line.isDoubleWidth()) {
+                    putDoubleWidthCharXY(line, (i * 2), row, newCell);
+                } else {
+                    putCharXY(i, row, newCell);
+                }
+            }
+            row++;
+        }
+    }
+
+    /**
+     * Set current value of the vertical scroll.
+     *
+     * @param value the new scroll value
+     */
+    @Override
+    public void setVerticalValue(final int value) {
+        super.setVerticalValue(value);
+        dirty = true;
+    }
+
+    /**
+     * Perform a small step change up.
+     */
+    @Override
+    public void verticalDecrement() {
+        super.verticalDecrement();
+        dirty = true;
+    }
+
+    /**
+     * Perform a small step change down.
+     */
+    @Override
+    public void verticalIncrement() {
+        super.verticalIncrement();
+        dirty = true;
+    }
+
+    /**
+     * Perform a big step change up.
+     */
+    public void bigVerticalDecrement() {
+        super.bigVerticalDecrement();
+        dirty = true;
+    }
+
+    /**
+     * Perform a big step change down.
+     */
+    public void bigVerticalIncrement() {
+        super.bigVerticalIncrement();
+        dirty = true;
+    }
+
+    /**
+     * Go to the top edge of the vertical scroller.
+     */
+    public void toTop() {
+        super.toTop();
+        dirty = true;
+    }
+
+    /**
+     * Go to the bottom edge of the vertical scroller.
+     */
+    public void toBottom() {
+        super.toBottom();
+        dirty = true;
+    }
+
+    /**
+     * Handle widget close.
+     */
+    @Override
+    public void close() {
+        if (emulator != null) {
+            emulator.close();
+        }
+        if (shell != null) {
+            terminateShellChildProcess();
+            shell.destroy();
+            shell = null;
+        }
+    }
+
+    /**
+     * Resize scrollbars for a new width/height.
+     */
+    @Override
+    public void reflowData() {
+        if (emulator == null) {
+            return;
+        }
+
+        // Synchronize against the emulator so we don't stomp on its reader
+        // thread.
+        synchronized (emulator) {
+
+            // Pull cursor information
+            readEmulatorState();
+
+            // Vertical scrollbar
+            setTopValue(getHeight()
+                - (emulator.getScrollbackBuffer().size()
+                    + emulator.getDisplayBuffer().size()));
+            setVerticalBigChange(getHeight());
+
+        } // synchronized (emulator)
+    }
+
+    // ------------------------------------------------------------------------
+    // TTerminalWidget --------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check for 'ptypipe' on the path.  If available, set ptypipeOnPath.
+     */
+    private static void checkForPtypipe() {
+        String systemPath = System.getenv("PATH");
+        if (systemPath == null) {
+            return;
+        }
+
+        String [] paths = systemPath.split(File.pathSeparator);
+        if (paths == null) {
+            return;
+        }
+        if (paths.length == 0) {
+            return;
+        }
+        for (int i = 0; i < paths.length; i++) {
+            File path = new File(paths[i]);
+            if (path.exists() && path.isDirectory()) {
+                File [] files = path.listFiles();
+                if (files == null) {
+                    continue;
+                }
+                if (files.length == 0) {
+                    continue;
+                }
+                for (int j = 0; j < files.length; j++) {
+                    File file = files[j];
+                    if (file.canExecute() && file.getName().equals("ptypipe")) {
+                        ptypipeOnPath = true;
+                        return;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the desired window title.
+     *
+     * @return the title
+     */
+    public String getTitle() {
+        return title;
+    }
+
+    /**
+     * Returns true if this widget does not want the application-wide mouse
+     * cursor drawn over it.
+     *
+     * @return true if this widget does not want the application-wide mouse
+     * cursor drawn over it
+     */
+    public boolean hasHiddenMouse() {
+        if (emulator == null) {
+            return false;
+        }
+        return (emulator.hasHiddenMousePointer() || typingHidMouse);
+    }
+
+    /**
+     * See if the terminal is still running.
+     *
+     * @return if true, we are still connected to / reading from the remote
+     * side
+     */
+    public boolean isReading() {
+        if (emulator == null) {
+            return false;
+        }
+        return emulator.isReading();
+    }
+
+    /**
+     * Convert a string array to a whitespace-separated string.
+     *
+     * @param array the string array
+     * @return a single string
+     */
+    private String stringArrayToString(final String [] array) {
+        StringBuilder sb = new StringBuilder(array[0].length());
+        for (int i = 0; i < array.length; i++) {
+            sb.append(array[i]);
+            if (i < array.length - 1) {
+                sb.append(' ');
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Spawn the shell.
+     *
+     * @param command the command line to execute
+     */
+    private void spawnShell(final String [] command) {
+
+        /*
+        System.err.printf("spawnShell(): '%s'\n",
+            stringArrayToString(command));
+        */
+
+        // We will have vScroller for its data fields and mouse event
+        // handling, but do not want to draw it.
+        vScroller = new TVScroller(null, getWidth(), 0, getHeight());
+        vScroller.setVisible(false);
+        setBottomValue(0);
+
+        title = i18n.getString("windowTitle");
+
+        // Assume XTERM
+        ECMA48.DeviceType deviceType = ECMA48.DeviceType.XTERM;
+
+        try {
+            ProcessBuilder pb = new ProcessBuilder(command);
+            Map<String, String> env = pb.environment();
+            env.put("TERM", ECMA48.deviceTypeTerm(deviceType));
+            env.put("LANG", ECMA48.deviceTypeLang(deviceType, "en"));
+            env.put("COLUMNS", "80");
+            env.put("LINES", "24");
+            pb.redirectErrorStream(true);
+            shell = pb.start();
+            emulator = new ECMA48(deviceType, shell.getInputStream(),
+                shell.getOutputStream(), this);
+        } catch (IOException e) {
+            messageBox(i18n.getString("errorLaunchingShellTitle"),
+                MessageFormat.format(i18n.getString("errorLaunchingShellText"),
+                    e.getMessage()));
+        }
+
+        // Setup the scroll bars
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+
+        // Hide mouse when typing option
+        if (System.getProperty("jexer.TTerminal.hideMouseWhenTyping",
+                "true").equals("false")) {
+
+            hideMouseWhenTyping = false;
+        }
+    }
+
+    /**
+     * Terminate the child of the 'script' process used on POSIX.  This may
+     * or may not work.
+     */
+    private void terminateShellChildProcess() {
+        int pid = -1;
+        if (shell.getClass().getName().equals("java.lang.UNIXProcess")) {
+            /* get the PID on unix/linux systems */
+            try {
+                Field field = shell.getClass().getDeclaredField("pid");
+                field.setAccessible(true);
+                pid = field.getInt(shell);
+            } catch (Throwable e) {
+                // SQUASH, this didn't work.  Just bail out quietly.
+                return;
+            }
+        }
+        if (pid != -1) {
+            // shell.destroy() works successfully at killing this side of
+            // 'script'.  But we need to make sure the other side (child
+            // process) is also killed.
+            String [] cmdKillIt = {
+                "pkill", "-P", Integer.toString(pid)
+            };
+            try {
+                Runtime.getRuntime().exec(cmdKillIt);
+            } catch (Throwable e) {
+                // SQUASH, this didn't work.  Just bail out quietly.
+                return;
+            }
+        }
+    }
+
+    /**
+     * Hook for subclasses to be notified of the shell termination.
+     */
+    public void onShellExit() {
+        TApplication app = getApplication();
+        if (app != null) {
+            if (closeAction != null) {
+                // We have to put this action inside invokeLater() because it
+                // could be executed during draw() when syncing with ECMA48.
+                app.invokeLater(new Runnable() {
+                    public void run() {
+                        closeAction.DO(TTerminalWidget.this);
+                    }
+                });
+            }
+            app.doRepaint();
+        }
+    }
+
+    /**
+     * Copy out variables from the emulator that TTerminal has to expose on
+     * screen.
+     */
+    private void readEmulatorState() {
+        if (emulator == null) {
+            return;
+        }
+
+        // Synchronize against the emulator so we don't stomp on its reader
+        // thread.
+        synchronized (emulator) {
+
+            setCursorX(emulator.getCursorX());
+            setCursorY(emulator.getCursorY()
+                + (getHeight() - emulator.getHeight())
+                - getVerticalValue());
+            setCursorVisible(emulator.isCursorVisible());
+            if (getCursorX() > getWidth()) {
+                setCursorVisible(false);
+            }
+            if ((getCursorY() >= getHeight()) || (getCursorY() < 0)) {
+                setCursorVisible(false);
+            }
+            if (emulator.getScreenTitle().length() > 0) {
+                // Only update the title if the shell is still alive
+                if (shell != null) {
+                    title = emulator.getScreenTitle();
+                }
+            }
+
+            // Check to see if the shell has died.
+            if (!emulator.isReading() && (shell != null)) {
+                try {
+                    int rc = shell.exitValue();
+                    // The emulator exited on its own, all is fine
+                    title = MessageFormat.format(i18n.
+                        getString("windowTitleCompleted"), title, rc);
+                    exitValue = rc;
+                    shell = null;
+                    emulator.close();
+                    onShellExit();
+                } catch (IllegalThreadStateException e) {
+                    // The emulator thread has exited, but the shell Process
+                    // hasn't figured that out yet.  Do nothing, we will see
+                    // this in a future tick.
+                }
+            } else if (emulator.isReading() && (shell != null)) {
+                // The shell might be dead, let's check
+                try {
+                    int rc = shell.exitValue();
+                    // If we got here, the shell died.
+                    title = MessageFormat.format(i18n.
+                        getString("windowTitleCompleted"), title, rc);
+                    exitValue = rc;
+                    shell = null;
+                    emulator.close();
+                    onShellExit();
+                } catch (IllegalThreadStateException e) {
+                    // The shell is still running, do nothing.
+                }
+            }
+
+        } // synchronized (emulator)
+    }
+
+    /**
+     * Wait for a period of time to get output from the launched process.
+     *
+     * @param millis millis to wait for, or 0 to wait forever
+     * @return true if the launched process has emitted something
+     */
+    public boolean waitForOutput(final int millis) {
+        if (emulator == null) {
+            return false;
+        }
+        return emulator.waitForOutput(millis);
+    }
+
+    /**
+     * Check if a mouse press/release/motion event coordinate is over the
+     * emulator.
+     *
+     * @param mouse a mouse-based event
+     * @return whether or not the mouse is on the emulator
+     */
+    private boolean mouseOnEmulator(final TMouseEvent mouse) {
+        if (emulator == null) {
+            return false;
+        }
+
+        if (!emulator.isReading()) {
+            return false;
+        }
+
+        if ((mouse.getX() >= 0)
+            && (mouse.getX() < getWidth() - 1)
+            && (mouse.getY() >= 0)
+            && (mouse.getY() < getHeight())
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Draw glyphs for a double-width or double-height VT100 cell to two
+     * screen cells.
+     *
+     * @param line the line this VT100 cell is in
+     * @param x the X position to draw the left half to
+     * @param y the Y position to draw to
+     * @param cell the cell to draw
+     */
+    private void putDoubleWidthCharXY(final DisplayLine line, final int x,
+        final int y, final Cell cell) {
+
+        int textWidth = getScreen().getTextWidth();
+        int textHeight = getScreen().getTextHeight();
+        boolean cursorBlinkVisible = true;
+
+        if (getScreen() instanceof SwingTerminal) {
+            SwingTerminal terminal = (SwingTerminal) getScreen();
+            cursorBlinkVisible = terminal.getCursorBlinkVisible();
+        } else if (getScreen() instanceof ECMA48Terminal) {
+            ECMA48Terminal terminal = (ECMA48Terminal) getScreen();
+
+            if (!terminal.hasSixel()) {
+                // The backend does not have sixel support, draw this as text
+                // and bail out.
+                putCharXY(x, y, cell);
+                putCharXY(x + 1, y, ' ', cell);
+                return;
+            }
+            cursorBlinkVisible = blinkState;
+        } else {
+            // We don't know how to dray glyphs to this screen, draw them as
+            // text and bail out.
+            putCharXY(x, y, cell);
+            putCharXY(x + 1, y, ' ', cell);
+            return;
+        }
+
+        if ((textWidth != lastTextWidth) || (textHeight != lastTextHeight)) {
+            // Screen size has changed, reset the font.
+            setupFont(textHeight);
+            lastTextWidth = textWidth;
+            lastTextHeight = textHeight;
+        }
+        assert (doubleFont != null);
+
+        BufferedImage image;
+        if (line.getDoubleHeight() == 1) {
+            // Double-height top half: don't draw the underline.
+            Cell newCell = new Cell(cell);
+            newCell.setUnderline(false);
+            image = doubleFont.getImage(newCell, textWidth * 2, textHeight * 2,
+                cursorBlinkVisible);
+        } else {
+            image = doubleFont.getImage(cell,  textWidth * 2, textHeight * 2,
+                cursorBlinkVisible);
+        }
+
+        // Now that we have the double-wide glyph drawn, copy the right
+        // pieces of it to the cells.
+        Cell left = new Cell(cell);
+        Cell right = new Cell(cell);
+        right.setChar(' ');
+        BufferedImage leftImage = null;
+        BufferedImage rightImage = null;
+        /*
+        System.err.println("image " + image + " textWidth " + textWidth +
+            " textHeight " + textHeight);
+         */
+
+        switch (line.getDoubleHeight()) {
+        case 1:
+            // Top half double height
+            leftImage = image.getSubimage(0, 0, textWidth, textHeight);
+            rightImage = image.getSubimage(textWidth, 0, textWidth, textHeight);
+            break;
+        case 2:
+            // Bottom half double height
+            leftImage = image.getSubimage(0, textHeight, textWidth, textHeight);
+            rightImage = image.getSubimage(textWidth, textHeight,
+                textWidth, textHeight);
+            break;
+        default:
+            // Either single height double-width, or error fallback
+            BufferedImage wideImage = new BufferedImage(textWidth * 2,
+                textHeight, BufferedImage.TYPE_INT_ARGB);
+            Graphics2D grWide = wideImage.createGraphics();
+            grWide.drawImage(image, 0, 0, wideImage.getWidth(),
+                wideImage.getHeight(), null);
+            grWide.dispose();
+            leftImage = wideImage.getSubimage(0, 0, textWidth, textHeight);
+            rightImage = wideImage.getSubimage(textWidth, 0, textWidth,
+                textHeight);
+            break;
+        }
+        left.setImage(leftImage);
+        right.setImage(rightImage);
+        // Since we have image data, ditch the character here.  Otherwise, a
+        // drawBoxShadow() over the terminal window will show the characters
+        // which looks wrong.
+        left.setChar(' ');
+        right.setChar(' ');
+        putCharXY(x, y, left);
+        putCharXY(x + 1, y, right);
+    }
+
+    /**
+     * Set up the double-width font.
+     *
+     * @param fontSize the size of font to request for the single-width font.
+     * The double-width font will be 2x this value.
+     */
+    private void setupFont(final int fontSize) {
+        doubleFont = GlyphMaker.getInstance(fontSize * 2);
+
+        // Special case: the ECMA48 backend needs to have a timer to drive
+        // its blink state.
+        if (getScreen() instanceof jexer.backend.ECMA48Terminal) {
+            if (!haveTimer) {
+                // Blink every 500 millis.
+                long millis = 500;
+                getApplication().addTimer(millis, true,
+                    new TAction() {
+                        public void DO() {
+                            blinkState = !blinkState;
+                            getApplication().doRepaint();
+                        }
+                    }
+                );
+                haveTimer = true;
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // DisplayListener --------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Called by emulator when fresh data has come in.
+     */
+    public void displayChanged() {
+        if (emulator != null) {
+            // Force sync here: EMCA48.run() thread might be setting
+            // dirty=true while TTerminalWdiget.draw() is setting
+            // dirty=false.  If these writes start interleaving, the display
+            // stops getting updated.
+            synchronized (emulator) {
+                dirty = true;
+            }
+        } else {
+            dirty = true;
+        }
+        getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
+    }
+
+    /**
+     * Function to call to obtain the display width.
+     *
+     * @return the number of columns in the display
+     */
+    public int getDisplayWidth() {
+        if (ptypipe) {
+            return getWidth();
+        }
+        return 80;
+    }
+
+    /**
+     * Function to call to obtain the display height.
+     *
+     * @return the number of rows in the display
+     */
+    public int getDisplayHeight() {
+        if (ptypipe) {
+            return getHeight();
+        }
+        return 24;
+    }
+
+    /**
+     * Get the exit value for the emulator.
+     *
+     * @return exit value
+     */
+    public int getExitValue() {
+        return exitValue;
+    }
+
+    // ------------------------------------------------------------------------
+    // EditMenuUser -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if the cut menu item should be enabled.
+     *
+     * @return true if the cut menu item should be enabled
+     */
+    public boolean isEditMenuCut() {
+        return false;
+    }
+
+    /**
+     * Check if the copy menu item should be enabled.
+     *
+     * @return true if the copy menu item should be enabled
+     */
+    public boolean isEditMenuCopy() {
+        return false;
+    }
+
+    /**
+     * Check if the paste menu item should be enabled.
+     *
+     * @return true if the paste menu item should be enabled
+     */
+    public boolean isEditMenuPaste() {
+        return true;
+    }
+
+    /**
+     * Check if the clear menu item should be enabled.
+     *
+     * @return true if the clear menu item should be enabled
+     */
+    public boolean isEditMenuClear() {
+        return false;
+    }
+
+}
diff --git a/src/jexer/TTerminalWidget.properties b/src/jexer/TTerminalWidget.properties
new file mode 100644 (file)
index 0000000..ecfcf21
--- /dev/null
@@ -0,0 +1,6 @@
+windowTitle=Terminal
+errorLaunchingShellTitle=Error
+errorLaunchingShellText=Error launching shell: {0}
+statusBarRunning=Terminal session executing...
+windowTitleCompleted={0} [Completed - {1}]
+statusBarCompleted=Terminal session completed, exit code {0}.
diff --git a/src/jexer/TTerminalWindow.java b/src/jexer/TTerminalWindow.java
new file mode 100644 (file)
index 0000000..754b7a5
--- /dev/null
@@ -0,0 +1,480 @@
+/*
+ * 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.menu.TMenu;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TTerminalWindow exposes a ECMA-48 / ANSI X3.64 style terminal in a window.
+ */
+public class TTerminalWindow extends TScrollableWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TTerminalWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The terminal.
+     */
+    private TTerminalWidget terminal;
+
+    /**
+     * If true, close the window when the shell exits.
+     */
+    private boolean closeOnExit = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param commandLine the command line to execute
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final String commandLine) {
+
+        this(application, x, y, RESIZABLE, commandLine.split("\\s+"),
+            System.getProperty("jexer.TTerminal.closeOnExit",
+                "false").equals("true"));
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param commandLine the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final String commandLine, final boolean closeOnExit) {
+
+        this(application, x, y, RESIZABLE, commandLine.split("\\s+"),
+            closeOnExit);
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param command the command line to execute
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final int flags, final String [] command) {
+
+        this(application, x, y, flags, command,
+            System.getProperty("jexer.TTerminal.closeOnExit",
+                "false").equals("true"));
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param command the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final int flags, final String [] command,
+        final boolean closeOnExit) {
+
+        super(application, i18n.getString("windowTitle"), x, y,
+            80 + 2, 24 + 2, flags);
+
+        // Require at least one line for the display.
+        setMinimumWindowHeight(3);
+
+        this.closeOnExit = closeOnExit;
+        vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+
+        // Claim the keystrokes the emulator will need.
+        addShortcutKeys();
+
+        // Add shortcut text
+        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() {
+            public void DO() {
+                onShellExit();
+            }
+        });
+    }
+
+    /**
+     * Public constructor spawns a shell.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final int flags) {
+
+        this(application, x, y, flags,
+            System.getProperty("jexer.TTerminal.closeOnExit",
+                "false").equals("true"));
+
+    }
+
+    /**
+     * Public constructor spawns a shell.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param closeOnExit if true, close the window when the shell exits
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final int flags, final boolean closeOnExit) {
+
+        super(application, i18n.getString("windowTitle"), x, y,
+            80 + 2, 24 + 2, flags);
+
+        // Require at least one line for the display.
+        setMinimumWindowHeight(3);
+
+        this.closeOnExit = closeOnExit;
+        vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+
+        // Claim the keystrokes the emulator will need.
+        addShortcutKeys();
+
+        // Add shortcut text
+        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() {
+            public void DO() {
+                onShellExit();
+            }
+        });
+    }
+
+    // ------------------------------------------------------------------------
+    // TScrollableWindow ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the display buffer.
+     */
+    @Override
+    public void draw() {
+        if (terminal != null) {
+            setTitle(terminal.getTitle());
+        }
+        reflowData();
+        super.draw();
+    }
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            if (terminal != null) {
+                terminal.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        getWidth() - 2, getHeight() - 2));
+            }
+
+            // Resize the scroll bars
+            reflowData();
+            placeScrollbars();
+        }
+        return;
+    }
+
+    /**
+     * Resize scrollbars for a new width/height.
+     */
+    @Override
+    public void reflowData() {
+        // Vertical scrollbar
+        if (terminal != null) {
+            terminal.reflowData();
+            setTopValue(terminal.getTopValue());
+            setBottomValue(terminal.getBottomValue());
+            setVerticalBigChange(terminal.getVerticalBigChange());
+            setVerticalValue(terminal.getVerticalValue());
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if ((terminal != null)
+            && (terminal.isReading())
+            && (!inKeyboardResize)
+        ) {
+            terminal.onKeypress(keypress);
+        } else {
+            super.onKeypress(keypress);
+        }
+    }
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (inWindowMove || inWindowResize) {
+            // TWindow needs to deal with this.
+            super.onMouseDown(mouse);
+            return;
+        }
+
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        if (inWindowMove || inWindowResize) {
+            // TWindow needs to deal with this.
+            super.onMouseUp(mouse);
+            return;
+        }
+
+        super.onMouseUp(mouse);
+
+        if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+            // Clicked on vertical scrollbar
+            if (terminal != null) {
+                terminal.setVerticalValue(getVerticalValue());
+            }
+        }
+    }
+
+    /**
+     * Handle mouse motion events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        if (inWindowMove || inWindowResize) {
+            // TWindow needs to deal with this.
+            super.onMouseMotion(mouse);
+            return;
+        }
+
+        super.onMouseMotion(mouse);
+
+        if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+            // Clicked/dragged on vertical scrollbar
+            if (terminal != null) {
+                terminal.setVerticalValue(getVerticalValue());
+            }
+        }
+    }
+
+    /**
+     * Get this window's help topic to load.
+     *
+     * @return the topic name
+     */
+    @Override
+    public String getHelpTopic() {
+        return "Terminal Window";
+    }
+
+    // ------------------------------------------------------------------------
+    // TTerminalWindow --------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if this window does not want the application-wide mouse
+     * cursor drawn over it.
+     *
+     * @return true if this window does not want the application-wide mouse
+     * cursor drawn over it
+     */
+    @Override
+    public boolean hasHiddenMouse() {
+        if (terminal != null) {
+            return terminal.hasHiddenMouse();
+        }
+        return false;
+    }
+
+    /**
+     * Claim the keystrokes the emulator will need.
+     */
+    private void addShortcutKeys() {
+        addShortcutKeypress(kbCtrlA);
+        addShortcutKeypress(kbCtrlB);
+        addShortcutKeypress(kbCtrlC);
+        addShortcutKeypress(kbCtrlD);
+        addShortcutKeypress(kbCtrlE);
+        addShortcutKeypress(kbCtrlF);
+        addShortcutKeypress(kbCtrlG);
+        addShortcutKeypress(kbCtrlH);
+        addShortcutKeypress(kbCtrlU);
+        addShortcutKeypress(kbCtrlJ);
+        addShortcutKeypress(kbCtrlK);
+        addShortcutKeypress(kbCtrlL);
+        addShortcutKeypress(kbCtrlM);
+        addShortcutKeypress(kbCtrlN);
+        addShortcutKeypress(kbCtrlO);
+        addShortcutKeypress(kbCtrlP);
+        addShortcutKeypress(kbCtrlQ);
+        addShortcutKeypress(kbCtrlR);
+        addShortcutKeypress(kbCtrlS);
+        addShortcutKeypress(kbCtrlT);
+        addShortcutKeypress(kbCtrlU);
+        addShortcutKeypress(kbCtrlV);
+        addShortcutKeypress(kbCtrlW);
+        addShortcutKeypress(kbCtrlX);
+        addShortcutKeypress(kbCtrlY);
+        addShortcutKeypress(kbCtrlZ);
+        addShortcutKeypress(kbF1);
+        addShortcutKeypress(kbF2);
+        addShortcutKeypress(kbF3);
+        addShortcutKeypress(kbF4);
+        addShortcutKeypress(kbF5);
+        addShortcutKeypress(kbF6);
+        addShortcutKeypress(kbF7);
+        addShortcutKeypress(kbF8);
+        addShortcutKeypress(kbF9);
+        addShortcutKeypress(kbF10);
+        addShortcutKeypress(kbF11);
+        addShortcutKeypress(kbF12);
+        addShortcutKeypress(kbAltA);
+        addShortcutKeypress(kbAltB);
+        addShortcutKeypress(kbAltC);
+        addShortcutKeypress(kbAltD);
+        addShortcutKeypress(kbAltE);
+        addShortcutKeypress(kbAltF);
+        addShortcutKeypress(kbAltG);
+        addShortcutKeypress(kbAltH);
+        addShortcutKeypress(kbAltU);
+        addShortcutKeypress(kbAltJ);
+        addShortcutKeypress(kbAltK);
+        addShortcutKeypress(kbAltL);
+        addShortcutKeypress(kbAltM);
+        addShortcutKeypress(kbAltN);
+        addShortcutKeypress(kbAltO);
+        addShortcutKeypress(kbAltP);
+        addShortcutKeypress(kbAltQ);
+        addShortcutKeypress(kbAltR);
+        addShortcutKeypress(kbAltS);
+        addShortcutKeypress(kbAltT);
+        addShortcutKeypress(kbAltU);
+        addShortcutKeypress(kbAltV);
+        addShortcutKeypress(kbAltW);
+        addShortcutKeypress(kbAltX);
+        addShortcutKeypress(kbAltY);
+        addShortcutKeypress(kbAltZ);
+    }
+
+    /**
+     * Hook for subclasses to be notified of the shell termination.
+     */
+    public void onShellExit() {
+        if (closeOnExit) {
+            close();
+        }
+        clearShortcutKeypresses();
+        getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
+    }
+
+    /**
+     * Wait for a period of time to get output from the launched process.
+     *
+     * @param millis millis to wait for, or 0 to wait forever
+     * @return true if the launched process has emitted something
+     */
+    public boolean waitForOutput(final int millis) {
+        if (terminal == null) {
+            return false;
+        }
+        return terminal.waitForOutput(millis);
+    }
+
+    /**
+     * Get the exit value for the emulator.
+     *
+     * @return exit value
+     */
+    public int getExitValue() {
+        if (terminal == null) {
+            return -1;
+        }
+        return terminal.getExitValue();
+    }
+
+}
diff --git a/src/jexer/TTerminalWindow.properties b/src/jexer/TTerminalWindow.properties
new file mode 100644 (file)
index 0000000..44a19f6
--- /dev/null
@@ -0,0 +1,4 @@
+windowTitle=Terminal
+statusBarRunning=Terminal session executing...
+statusBarHelp=Help
+statusBarMenu=Menu
diff --git a/src/jexer/TText.java b/src/jexer/TText.java
new file mode 100644 (file)
index 0000000..f6d7feb
--- /dev/null
@@ -0,0 +1,453 @@
+/*
+ * 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.Arrays;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.kbDown;
+import static jexer.TKeypress.kbEnd;
+import static jexer.TKeypress.kbHome;
+import static jexer.TKeypress.kbLeft;
+import static jexer.TKeypress.kbPgDn;
+import static jexer.TKeypress.kbPgUp;
+import static jexer.TKeypress.kbRight;
+import static jexer.TKeypress.kbUp;
+
+/**
+ * TText implements a simple scrollable text area. It reflows automatically on
+ * resize.
+ */
+public class TText extends TScrollableWidget {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Available text justifications.
+     */
+    public enum Justification {
+
+        /**
+         * Not justified at all, use spacing as provided by the client.
+         */
+        NONE,
+
+        /**
+         * Left-justified text.
+         */
+        LEFT,
+
+        /**
+         * Centered text.
+         */
+        CENTER,
+
+        /**
+         * Right-justified text.
+         */
+        RIGHT,
+
+        /**
+         * Fully-justified text.
+         */
+        FULL,
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * How to justify the text.
+     */
+    private Justification justification = Justification.LEFT;
+
+    /**
+     * Text to display.
+     */
+    private String text;
+
+    /**
+     * Text converted to lines.
+     */
+    private List<String> lines;
+
+    /**
+     * Text color.
+     */
+    private String colorKey;
+
+    /**
+     * Maximum width of a single line.
+     */
+    private int maxLineWidth;
+
+    /**
+     * Number of lines between each paragraph.
+     */
+    private int lineSpacing = 1;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text text on the screen
+     * @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 TText(final TWidget parent, final String text, final int x,
+            final int y, final int width, final int height) {
+
+        this(parent, text, x, y, width, height, "ttext");
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param text text on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param colorKey ColorTheme key color to use for foreground
+     * text. Default is "ttext".
+     */
+    public TText(final TWidget parent, final String text, final int x,
+            final int y, final int width, final int height,
+            final String colorKey) {
+
+        // Set parent and window
+        super(parent, x, y, width, height);
+
+        this.text = text;
+        this.colorKey = colorKey;
+
+        lines = new ArrayList<String>();
+
+        vScroller = new TVScroller(this, getWidth() - 1, 0,
+            Math.max(1, getHeight() - 1));
+        hScroller = new THScroller(this, 0, getHeight() - 1,
+            Math.max(1, getWidth() - 1));
+        reflowData();
+    }
+
+    // ------------------------------------------------------------------------
+    // 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(getHeight() - 1);
+        }
+    }
+
+    /**
+     * Draw the text box.
+     */
+    @Override
+    public void draw() {
+        // Setup my color
+        CellAttributes color = getTheme().getColor(colorKey);
+
+        int begin = vScroller.getValue();
+        int topY = 0;
+        for (int i = begin; i < lines.size(); i++) {
+            String line = lines.get(i);
+            if (hScroller.getValue() < StringUtils.width(line)) {
+                line = line.substring(hScroller.getValue());
+            } else {
+                line = "";
+            }
+            if (getWidth() > 3) {
+                String formatString = "%-" + Integer.toString(getWidth() - 1) + "s";
+                putStringXY(0, topY, String.format(formatString, line), color);
+            }
+            topY++;
+
+            if (topY >= (getHeight() - 1)) {
+                break;
+            }
+        }
+
+        // Pad the rest with blank lines
+        for (int i = topY; i < (getHeight() - 1); i++) {
+            hLineXY(0, i, getWidth() - 1, ' ', color);
+        }
+
+    }
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouse.isMouseWheelUp()) {
+            vScroller.decrement();
+            return;
+        }
+        if (mouse.isMouseWheelDown()) {
+            vScroller.increment();
+            return;
+        }
+
+        // Pass to children
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbLeft)) {
+            hScroller.decrement();
+        } else if (keypress.equals(kbRight)) {
+            hScroller.increment();
+        } else if (keypress.equals(kbUp)) {
+            vScroller.decrement();
+        } else if (keypress.equals(kbDown)) {
+            vScroller.increment();
+        } else if (keypress.equals(kbPgUp)) {
+            vScroller.bigDecrement();
+        } else if (keypress.equals(kbPgDn)) {
+            vScroller.bigIncrement();
+        } else if (keypress.equals(kbHome)) {
+            vScroller.toTop();
+        } else if (keypress.equals(kbEnd)) {
+            vScroller.toBottom();
+        } else {
+            // Pass other keys (tab etc.) on
+            super.onKeypress(keypress);
+        }
+    }
+
+    /**
+     * Resize text and scrollbars for a new width/height.
+     */
+    @Override
+    public void reflowData() {
+        // Reset the lines
+        lines.clear();
+
+        // Break up text into paragraphs
+        String[] paragraphs = text.split("\n\n");
+        for (String p : paragraphs) {
+            switch (justification) {
+            case NONE:
+                lines.addAll(Arrays.asList(p.split("\n")));
+                break;
+            case LEFT:
+                lines.addAll(jexer.bits.StringUtils.left(p,
+                        getWidth() - 1));
+                break;
+            case CENTER:
+                lines.addAll(jexer.bits.StringUtils.center(p,
+                        getWidth() - 1));
+                break;
+            case RIGHT:
+                lines.addAll(jexer.bits.StringUtils.right(p,
+                        getWidth() - 1));
+                break;
+            case FULL:
+                lines.addAll(jexer.bits.StringUtils.full(p,
+                        getWidth() - 1));
+                break;
+            }
+
+            for (int i = 0; i < lineSpacing; i++) {
+                lines.add("");
+            }
+        }
+        computeBounds();
+    }
+
+    // ------------------------------------------------------------------------
+    // TText ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the text.
+     *
+     * @param text new text to display
+     */
+    public void setText(final String text) {
+        this.text = text;
+        reflowData();
+    }
+
+    /**
+     * Get the text.
+     *
+     * @return the text
+     */
+    public String getText() {
+        return text;
+    }
+
+    /**
+     * Convenience method used by TWindowLoggerOutput.
+     *
+     * @param line new line to add
+     */
+    public void addLine(final String line) {
+        if (StringUtils.width(text) == 0) {
+            text = line;
+        } else {
+            text += "\n\n";
+            text += line;
+        }
+        reflowData();
+    }
+
+    /**
+     * Recompute the bounds for the scrollbars.
+     */
+    private void computeBounds() {
+        maxLineWidth = 0;
+        for (String line : lines) {
+            if (StringUtils.width(line) > maxLineWidth) {
+                maxLineWidth = StringUtils.width(line);
+            }
+        }
+
+        vScroller.setTopValue(0);
+        vScroller.setBottomValue((lines.size() - getHeight()) + 1);
+        if (vScroller.getBottomValue() < 0) {
+            vScroller.setBottomValue(0);
+        }
+        if (vScroller.getValue() > vScroller.getBottomValue()) {
+            vScroller.setValue(vScroller.getBottomValue());
+        }
+
+        hScroller.setLeftValue(0);
+        hScroller.setRightValue((maxLineWidth - getWidth()) + 1);
+        if (hScroller.getRightValue() < 0) {
+            hScroller.setRightValue(0);
+        }
+        if (hScroller.getValue() > hScroller.getRightValue()) {
+            hScroller.setValue(hScroller.getRightValue());
+        }
+    }
+
+    /**
+     * Set justification.
+     *
+     * @param justification NONE, LEFT, CENTER, RIGHT, or FULL
+     */
+    public void setJustification(final Justification justification) {
+        this.justification = justification;
+        reflowData();
+    }
+
+    /**
+     * Left-justify the text.
+     */
+    public void leftJustify() {
+        justification = Justification.LEFT;
+        reflowData();
+    }
+
+    /**
+     * Center-justify the text.
+     */
+    public void centerJustify() {
+        justification = Justification.CENTER;
+        reflowData();
+    }
+
+    /**
+     * Right-justify the text.
+     */
+    public void rightJustify() {
+        justification = Justification.RIGHT;
+        reflowData();
+    }
+
+    /**
+     * Fully-justify the text.
+     */
+    public void fullJustify() {
+        justification = Justification.FULL;
+        reflowData();
+    }
+
+    /**
+     * Un-justify the text.
+     */
+    public void unJustify() {
+        justification = Justification.NONE;
+        reflowData();
+    }
+
+}
diff --git a/src/jexer/TTimer.java b/src/jexer/TTimer.java
new file mode 100644 (file)
index 0000000..8007153
--- /dev/null
@@ -0,0 +1,120 @@
+/*
+ * 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.Date;
+
+/**
+ * TTimer implements a simple timer.
+ */
+public class TTimer {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, re-schedule after every tick.  Note package private access.
+     */
+    boolean recurring = false;
+
+    /**
+     * Duration (in millis) between ticks if this is a recurring timer.
+     */
+    private long duration = 0;
+
+    /**
+     * The next time this timer needs to be ticked.
+     */
+    private Date nextTick;
+
+    /**
+     * The action to perfom on a tick.
+     */
+    private TAction action;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param duration number of milliseconds to wait between ticks
+     * @param recurring if true, re-schedule this timer after every tick
+     * @param action to perform on next tick
+     */
+    TTimer(final long duration, final boolean recurring, final TAction action) {
+
+        this.recurring = recurring;
+        this.duration  = duration;
+        this.action    = action;
+
+        Date now = new Date();
+        nextTick = new Date(now.getTime() + duration);
+    }
+
+    // ------------------------------------------------------------------------
+    // TTimer -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the next time this timer needs to be ticked.  Note package private
+     * access.
+     *
+     * @return time at which action should be called
+     */
+    Date getNextTick() {
+        return nextTick;
+    }
+
+    /**
+     * Set the recurring flag.
+     *
+     * @param recurring if true, re-schedule this timer after every tick
+     */
+    public void setRecurring(final boolean recurring) {
+        this.recurring = recurring;
+    }
+
+    /**
+     * Tick this timer.  Note package private access.
+     */
+    void tick() {
+        if (action != null) {
+            action.DO();
+        }
+        // Set next tick
+        Date ticked = new Date();
+        if (recurring) {
+            nextTick = new Date(ticked.getTime() + duration);
+        }
+    }
+
+}
diff --git a/src/jexer/TVScroller.java b/src/jexer/TVScroller.java
new file mode 100644 (file)
index 0000000..444e058
--- /dev/null
@@ -0,0 +1,402 @@
+/*
+ * 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 jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TMouseEvent;
+
+/**
+ * TVScroller implements a simple vertical scroll bar.
+ */
+public class TVScroller extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Value that corresponds to being on the top edge of the scroll bar.
+     */
+    private int topValue = 0;
+
+    /**
+     * Value that corresponds to being on the bottom edge of the scroll bar.
+     */
+    private int bottomValue = 100;
+
+    /**
+     * Current value of the scroll.
+     */
+    private int value = 0;
+
+    /**
+     * The increment for clicking on an arrow.
+     */
+    private int smallChange = 1;
+
+    /**
+     * The increment for clicking in the bar between the box and an arrow.
+     */
+    private int bigChange = 20;
+
+    /**
+     * When true, the user is dragging the scroll box.
+     */
+    private boolean inScroll = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param height height of scroll bar
+     */
+    public TVScroller(final TWidget parent, final int x, final int y,
+        final int height) {
+
+        // Set parent and window
+        super(parent, x, y, 1, height);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        if (bottomValue == topValue) {
+            return;
+        }
+
+        if (inScroll) {
+            inScroll = false;
+            return;
+        }
+
+        if ((mouse.getX() == 0)
+            && (mouse.getY() == 0)
+        ) {
+            // Clicked on the top arrow
+            decrement();
+            return;
+        }
+
+        if ((mouse.getX() == 0)
+            && (mouse.getY() == getHeight() - 1)
+        ) {
+            // Clicked on the bottom arrow
+            increment();
+            return;
+        }
+
+        if ((mouse.getX() == 0)
+            && (mouse.getY() > 0)
+            && (mouse.getY() < boxPosition())
+        ) {
+            // Clicked between the top arrow and the box
+            value -= bigChange;
+            if (value < topValue) {
+                value = topValue;
+            }
+            return;
+        }
+
+        if ((mouse.getX() == 0)
+            && (mouse.getY() > boxPosition())
+            && (mouse.getY() < getHeight() - 1)
+        ) {
+            // Clicked between the box and the bottom arrow
+            value += bigChange;
+            if (value > bottomValue) {
+                value = bottomValue;
+            }
+            return;
+        }
+    }
+
+    /**
+     * Handle mouse movement events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        if (bottomValue == topValue) {
+            return;
+        }
+
+        if ((mouse.isMouse1())
+            && (inScroll)
+            && (mouse.getY() > 0)
+            && (mouse.getY() < getHeight() - 1)
+        ) {
+            // Recompute value based on new box position
+            value = (bottomValue - topValue)
+                * (mouse.getY()) / (getHeight() - 3) + topValue;
+            if (value > bottomValue) {
+                value = bottomValue;
+            }
+            if (value < topValue) {
+                value = topValue;
+            }
+            return;
+        }
+
+        inScroll = false;
+    }
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (bottomValue == topValue) {
+            return;
+        }
+
+        if ((mouse.getX() == 0)
+            && (mouse.getY() == boxPosition())
+        ) {
+            inScroll = true;
+            return;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw a vertical scroll bar.
+     */
+    @Override
+    public void draw() {
+        CellAttributes arrowColor = getTheme().getColor("tscroller.arrows");
+        CellAttributes barColor = getTheme().getColor("tscroller.bar");
+        putCharXY(0, 0, GraphicsChars.CP437[0x1E], arrowColor);
+        putCharXY(0, getHeight() - 1, GraphicsChars.CP437[0x1F], arrowColor);
+
+        // Place the box
+        if (bottomValue > topValue) {
+            vLineXY(0, 1, getHeight() - 2, GraphicsChars.CP437[0xB1], barColor);
+            putCharXY(0, boxPosition(), GraphicsChars.BOX, arrowColor);
+        } else {
+            vLineXY(0, 1, getHeight() - 2, GraphicsChars.HATCH, barColor);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TVScroller -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the value that corresponds to being on the top edge of the scroll
+     * bar.
+     *
+     * @return the scroll value
+     */
+    public int getTopValue() {
+        return topValue;
+    }
+
+    /**
+     * Set the value that corresponds to being on the top edge of the scroll
+     * bar.
+     *
+     * @param topValue the new scroll value
+     */
+    public void setTopValue(final int topValue) {
+        this.topValue = topValue;
+    }
+
+    /**
+     * Get the value that corresponds to being on the bottom edge of the
+     * scroll bar.
+     *
+     * @return the scroll value
+     */
+    public int getBottomValue() {
+        return bottomValue;
+    }
+
+    /**
+     * Set the value that corresponds to being on the bottom edge of the
+     * scroll bar.
+     *
+     * @param bottomValue the new scroll value
+     */
+    public void setBottomValue(final int bottomValue) {
+        this.bottomValue = bottomValue;
+    }
+
+    /**
+     * Get current value of the scroll.
+     *
+     * @return the scroll value
+     */
+    public int getValue() {
+        return value;
+    }
+
+    /**
+     * Set current value of the scroll.
+     *
+     * @param value the new scroll value
+     */
+    public void setValue(final int value) {
+        this.value = value;
+    }
+
+    /**
+     * Get the increment for clicking on an arrow.
+     *
+     * @return the increment value
+     */
+    public int getSmallChange() {
+        return smallChange;
+    }
+
+    /**
+     * Set the increment for clicking on an arrow.
+     *
+     * @param smallChange the new increment value
+     */
+    public void setSmallChange(final int smallChange) {
+        this.smallChange = smallChange;
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow.
+     *
+     * @return the increment value
+     */
+    public int getBigChange() {
+        return bigChange;
+    }
+
+    /**
+     * Set the increment for clicking in the bar between the box and an
+     * arrow.
+     *
+     * @param bigChange the new increment value
+     */
+    public void setBigChange(final int bigChange) {
+        this.bigChange = bigChange;
+    }
+
+    /**
+     * Compute the position of the scroll box (a.k.a. grip, thumb).
+     *
+     * @return Y position of the box, between 1 and height - 2
+     */
+    private int boxPosition() {
+        return (getHeight() - 3) * (value - topValue) / (bottomValue - topValue) + 1;
+    }
+
+    /**
+     * Perform a small step change up.
+     */
+    public void decrement() {
+        if (bottomValue == topValue) {
+            return;
+        }
+        value -= smallChange;
+        if (value < topValue) {
+            value = topValue;
+        }
+    }
+
+    /**
+     * Perform a small step change down.
+     */
+    public void increment() {
+        if (bottomValue == topValue) {
+            return;
+        }
+        value += smallChange;
+        if (value > bottomValue) {
+            value = bottomValue;
+        }
+    }
+
+    /**
+     * Perform a big step change up.
+     */
+    public void bigDecrement() {
+        if (bottomValue == topValue) {
+            return;
+        }
+        value -= bigChange;
+        if (value < topValue) {
+            value = topValue;
+        }
+    }
+
+    /**
+     * Perform a big step change down.
+     */
+    public void bigIncrement() {
+        if (bottomValue == topValue) {
+            return;
+        }
+        value += bigChange;
+        if (value > bottomValue) {
+            value = bottomValue;
+        }
+    }
+
+    /**
+     * Go to the top edge of the scroller.
+     */
+    public void toTop() {
+        value = topValue;
+    }
+
+    /**
+     * Go to the bottom edge of the scroller.
+     */
+    public void toBottom() {
+        value = bottomValue;
+    }
+
+}
diff --git a/src/jexer/TWidget.java b/src/jexer/TWidget.java
new file mode 100644 (file)
index 0000000..d60efd8
--- /dev/null
@@ -0,0 +1,2794 @@
+/*
+ * 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.awt.image.BufferedImage;
+import java.io.IOException;
+import java.util.List;
+import java.util.ArrayList;
+
+import jexer.backend.Screen;
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.Clipboard;
+import jexer.bits.ColorTheme;
+import jexer.event.TCommandEvent;
+import jexer.event.TInputEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.layout.LayoutManager;
+import jexer.menu.TMenu;
+import jexer.ttree.TTreeItem;
+import jexer.ttree.TTreeView;
+import jexer.ttree.TTreeViewWidget;
+import static jexer.TKeypress.*;
+
+/**
+ * TWidget is the base class of all objects that can be drawn on screen or
+ * handle user input events.
+ */
+public abstract class TWidget implements Comparable<TWidget> {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Every widget has a parent widget that it may be "contained" in.  For
+     * example, a TWindow might contain several TFields, or a TComboBox may
+     * contain a TList that itself contains a TVScroller.
+     */
+    private TWidget parent = null;
+
+    /**
+     * Child widgets that this widget contains.
+     */
+    private List<TWidget> children;
+
+    /**
+     * The currently active child widget that will receive keypress events.
+     */
+    private TWidget activeChild = null;
+
+    /**
+     * If true, this widget will receive events.
+     */
+    private boolean active = false;
+
+    /**
+     * The window that this widget draws to.
+     */
+    private TWindow window = null;
+
+    /**
+     * Absolute X position of the top-left corner.
+     */
+    private int x = 0;
+
+    /**
+     * Absolute Y position of the top-left corner.
+     */
+    private int y = 0;
+
+    /**
+     * Width.
+     */
+    private int width = 0;
+
+    /**
+     * Height.
+     */
+    private int height = 0;
+
+    /**
+     * My tab order inside a window or containing widget.
+     */
+    private int tabOrder = 0;
+
+    /**
+     * If true, this widget can be tabbed to or receive events.
+     */
+    private boolean enabled = true;
+
+    /**
+     * If true, this widget will be rendered.
+     */
+    private boolean visible = true;
+
+    /**
+     * If true, this widget has a cursor.
+     */
+    private boolean cursorVisible = false;
+
+    /**
+     * Cursor column position in relative coordinates.
+     */
+    private int cursorX = 0;
+
+    /**
+     * Cursor row position in relative coordinates.
+     */
+    private int cursorY = 0;
+
+    /**
+     * Layout manager.
+     */
+    private LayoutManager layout = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Default constructor for subclasses.
+     */
+    protected TWidget() {
+        children = new ArrayList<TWidget>();
+    }
+
+    /**
+     * Protected constructor.
+     *
+     * @param parent parent widget
+     */
+    protected TWidget(final TWidget parent) {
+        this(parent, true);
+    }
+
+    /**
+     * Protected constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    protected TWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height) {
+
+        this(parent, true, x, y, width, height);
+    }
+
+    /**
+     * Protected constructor used by subclasses that are disabled by default.
+     *
+     * @param parent parent widget
+     * @param enabled if true assume enabled
+     */
+    protected TWidget(final TWidget parent, final boolean enabled) {
+        this(parent, enabled, 0, 0, 0, 0);
+    }
+
+    /**
+     * Protected constructor used by subclasses that are disabled by default.
+     *
+     * @param parent parent widget
+     * @param enabled if true assume enabled
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    protected TWidget(final TWidget parent, final boolean enabled,
+        final int x, final int y, final int width, final int height) {
+
+        if (width < 0) {
+            throw new IllegalArgumentException("width cannot be negative");
+        }
+        if (height < 0) {
+            throw new IllegalArgumentException("height cannot be negative");
+        }
+
+        this.enabled = enabled;
+        this.parent = parent;
+        children = new ArrayList<TWidget>();
+
+        this.x = x;
+        this.y = y;
+        this.width = width;
+        this.height = height;
+
+        if (parent != null) {
+            this.window = parent.window;
+            parent.addChild(this);
+        }
+    }
+
+    /**
+     * Backdoor access for TWindow's constructor.  ONLY TWindow USES THIS.
+     *
+     * @param window the top-level window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     */
+    protected final void setupForTWindow(final TWindow window,
+        final int x, final int y, final int width, final int height) {
+
+        if (width < 0) {
+            throw new IllegalArgumentException("width cannot be negative");
+        }
+        if (height < 0) {
+            throw new IllegalArgumentException("height cannot be negative");
+        }
+
+        this.parent = window;
+        this.window = window;
+        this.x      = x;
+        this.y      = y;
+        this.width  = width;
+        this.height = height;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Subclasses should override this method to cleanup resources.  This is
+     * called by TWindow.onClose().
+     */
+    protected void close() {
+        // Default: call close() on children.
+        for (TWidget w: getChildren()) {
+            w.close();
+        }
+    }
+
+    /**
+     * Check if a mouse press/release event coordinate is contained in this
+     * widget.
+     *
+     * @param mouse a mouse-based event
+     * @return whether or not a mouse click would be sent to this widget
+     */
+    public final boolean mouseWouldHit(final TMouseEvent mouse) {
+
+        if (!enabled) {
+            return false;
+        }
+
+        if ((this instanceof TTreeItem)
+            && ((y < 0) || (y > parent.getHeight() - 1))
+        ) {
+            return false;
+        }
+
+        if ((mouse.getAbsoluteX() >= getAbsoluteX())
+            && (mouse.getAbsoluteX() <  getAbsoluteX() + width)
+            && (mouse.getAbsoluteY() >= getAbsoluteY())
+            && (mouse.getAbsoluteY() <  getAbsoluteY() + height)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Method that subclasses can override to handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    public void onKeypress(final TKeypressEvent keypress) {
+        assert (parent != null);
+
+        if ((children.size() == 0)
+            || (this instanceof TTreeView)
+            || (this instanceof TText)
+            || (this instanceof TComboBox)
+        ) {
+
+            // Defaults:
+            //   tab / shift-tab - switch to next/previous widget
+            //   left-arrow or up-arrow: same as shift-tab
+            if ((keypress.equals(kbTab))
+                || (keypress.equals(kbDown) && !(this instanceof TComboBox))
+            ) {
+                parent.switchWidget(true);
+                return;
+            } else if ((keypress.equals(kbShiftTab))
+                || (keypress.equals(kbBackTab))
+                || (keypress.equals(kbUp) && !(this instanceof TComboBox))
+            ) {
+                parent.switchWidget(false);
+                return;
+            }
+        }
+
+        if ((children.size() == 0)
+            && !(this instanceof TTreeView)
+        ) {
+
+            // Defaults:
+            //   right-arrow or down-arrow: same as tab
+            if (keypress.equals(kbRight)) {
+                parent.switchWidget(true);
+                return;
+            } else if (keypress.equals(kbLeft)) {
+                parent.switchWidget(false);
+                return;
+            }
+        }
+
+        // If I have any buttons on me AND this is an Alt-key that matches
+        // its mnemonic, send it an Enter keystroke.
+        for (TWidget widget: children) {
+            if (widget instanceof TButton) {
+                TButton button = (TButton) widget;
+                if (button.isEnabled()
+                    && !keypress.getKey().isFnKey()
+                    && keypress.getKey().isAlt()
+                    && !keypress.getKey().isCtrl()
+                    && (Character.toLowerCase(button.getMnemonic().getShortcut())
+                        == Character.toLowerCase(keypress.getKey().getChar()))
+                ) {
+
+                    widget.onKeypress(new TKeypressEvent(kbEnter));
+                    return;
+                }
+            }
+        }
+
+        // If I have any labels on me AND this is an Alt-key that matches
+        // its mnemonic, call its action.
+        for (TWidget widget: children) {
+            if (widget instanceof TLabel) {
+                TLabel label = (TLabel) widget;
+                if (!keypress.getKey().isFnKey()
+                    && keypress.getKey().isAlt()
+                    && !keypress.getKey().isCtrl()
+                    && (Character.toLowerCase(label.getMnemonic().getShortcut())
+                        == Character.toLowerCase(keypress.getKey().getChar()))
+                ) {
+
+                    label.dispatch();
+                    return;
+                }
+            }
+        }
+
+        // If I have any radiobuttons on me AND this is an Alt-key that
+        // matches its mnemonic, select it and send a Space to it.
+        for (TWidget widget: children) {
+            if (widget instanceof TRadioButton) {
+                TRadioButton button = (TRadioButton) widget;
+                if (button.isEnabled()
+                    && !keypress.getKey().isFnKey()
+                    && keypress.getKey().isAlt()
+                    && !keypress.getKey().isCtrl()
+                    && (Character.toLowerCase(button.getMnemonic().getShortcut())
+                        == Character.toLowerCase(keypress.getKey().getChar()))
+                ) {
+                    activate(widget);
+                    widget.onKeypress(new TKeypressEvent(kbSpace));
+                    return;
+                }
+            }
+            if (widget instanceof TRadioGroup) {
+                for (TWidget child: widget.getChildren()) {
+                    if (child instanceof TRadioButton) {
+                        TRadioButton button = (TRadioButton) child;
+                        if (button.isEnabled()
+                            && !keypress.getKey().isFnKey()
+                            && keypress.getKey().isAlt()
+                            && !keypress.getKey().isCtrl()
+                            && (Character.toLowerCase(button.getMnemonic().getShortcut())
+                                == Character.toLowerCase(keypress.getKey().getChar()))
+                        ) {
+                            activate(widget);
+                            widget.activate(child);
+                            child.onKeypress(new TKeypressEvent(kbSpace));
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+
+        // If I have any checkboxes on me AND this is an Alt-key that matches
+        // its mnemonic, select it and set it to checked.
+        for (TWidget widget: children) {
+            if (widget instanceof TCheckBox) {
+                TCheckBox checkBox = (TCheckBox) widget;
+                if (checkBox.isEnabled()
+                    && !keypress.getKey().isFnKey()
+                    && keypress.getKey().isAlt()
+                    && !keypress.getKey().isCtrl()
+                    && (Character.toLowerCase(checkBox.getMnemonic().getShortcut())
+                        == Character.toLowerCase(keypress.getKey().getChar()))
+                ) {
+                    activate(checkBox);
+                    checkBox.setChecked(true);
+                    return;
+                }
+            }
+        }
+
+        // Dispatch the keypress to an active widget
+        for (TWidget widget: children) {
+            if (widget.active) {
+                widget.onKeypress(keypress);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    public void onMouseDown(final TMouseEvent mouse) {
+        // Default: do nothing, pass to children instead
+        if (activeChild != null) {
+            if (activeChild.mouseWouldHit(mouse)) {
+                // Dispatch to the active child
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - activeChild.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - activeChild.getAbsoluteY());
+                activeChild.onMouseDown(mouse);
+                return;
+            }
+        }
+        for (int i = children.size() - 1 ; i >= 0 ; i--) {
+            TWidget widget = children.get(i);
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.onMouseDown(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse button releases.
+     *
+     * @param mouse mouse button event
+     */
+    public void onMouseUp(final TMouseEvent mouse) {
+        // Default: do nothing, pass to children instead
+        if (activeChild != null) {
+            if (activeChild.mouseWouldHit(mouse)) {
+                // Dispatch to the active child
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - activeChild.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - activeChild.getAbsoluteY());
+                activeChild.onMouseUp(mouse);
+                return;
+            }
+        }
+        for (int i = children.size() - 1 ; i >= 0 ; i--) {
+            TWidget widget = children.get(i);
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.onMouseUp(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    public void onMouseMotion(final TMouseEvent mouse) {
+        // Default: do nothing, pass it on to ALL of my children.  This way
+        // the children can see the mouse "leaving" their area.
+        for (TWidget widget: children) {
+            // Set x and y relative to the child's coordinates
+            mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+            mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+            widget.onMouseMotion(mouse);
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle mouse button
+     * double-clicks.
+     *
+     * @param mouse mouse button event
+     */
+    public void onMouseDoubleClick(final TMouseEvent mouse) {
+        // Default: do nothing, pass to children instead
+        if (activeChild != null) {
+            if (activeChild.mouseWouldHit(mouse)) {
+                // Dispatch to the active child
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - activeChild.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - activeChild.getAbsoluteY());
+                activeChild.onMouseDoubleClick(mouse);
+                return;
+            }
+        }
+        for (int i = children.size() - 1 ; i >= 0 ; i--) {
+            TWidget widget = children.get(i);
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.onMouseDoubleClick(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle window/screen resize
+     * events.
+     *
+     * @param resize resize event
+     */
+    public void onResize(final TResizeEvent resize) {
+        // Default: change my width/height.
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            width = resize.getWidth();
+            height = resize.getHeight();
+            if (layout != null) {
+                if (this instanceof TWindow) {
+                    layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                            width - 2, height - 2));
+                } else {
+                    layout.onResize(resize);
+                }
+            }
+        } else {
+            // Let children see the screen resize
+            for (TWidget widget: children) {
+                widget.onResize(resize);
+            }
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle posted command events.
+     *
+     * @param command command event
+     */
+    public void onCommand(final TCommandEvent command) {
+        if (activeChild != null) {
+            activeChild.onCommand(command);
+        }
+    }
+
+    /**
+     * Method that subclasses can override to handle menu or posted menu
+     * events.
+     *
+     * @param menu menu event
+     */
+    public void onMenu(final TMenuEvent menu) {
+        // Default: do nothing, pass to children instead
+        for (TWidget widget: children) {
+            widget.onMenu(menu);
+        }
+    }
+
+    /**
+     * Method that subclasses can override to do processing when the UI is
+     * idle.  Note that repainting is NOT assumed.  To get a refresh after
+     * onIdle, call doRepaint().
+     */
+    public void onIdle() {
+        // Default: do nothing, pass to children instead
+        for (TWidget widget: children) {
+            widget.onIdle();
+        }
+    }
+
+    /**
+     * Consume event.  Subclasses that want to intercept all events in one go
+     * can override this method.
+     *
+     * @param event keyboard, mouse, resize, command, or menu event
+     */
+    public void handleEvent(final TInputEvent event) {
+        /*
+        System.err.printf("TWidget (%s) event: %s\n", this.getClass().getName(),
+            event);
+        */
+
+        if (!enabled) {
+            // Discard event
+            // System.err.println("   -- discard --");
+            return;
+        }
+
+        if (event instanceof TKeypressEvent) {
+            onKeypress((TKeypressEvent) event);
+        } else if (event instanceof TMouseEvent) {
+
+            TMouseEvent mouse = (TMouseEvent) event;
+
+            switch (mouse.getType()) {
+
+            case MOUSE_DOWN:
+                onMouseDown(mouse);
+                break;
+
+            case MOUSE_UP:
+                onMouseUp(mouse);
+                break;
+
+            case MOUSE_MOTION:
+                onMouseMotion(mouse);
+                break;
+
+            case MOUSE_DOUBLE_CLICK:
+                onMouseDoubleClick(mouse);
+                break;
+
+            default:
+                throw new IllegalArgumentException("Invalid mouse event type: "
+                    + mouse.getType());
+            }
+        } else if (event instanceof TResizeEvent) {
+            onResize((TResizeEvent) event);
+        } else if (event instanceof TCommandEvent) {
+            onCommand((TCommandEvent) event);
+        } else if (event instanceof TMenuEvent) {
+            onMenu((TMenuEvent) event);
+        }
+
+        // Do nothing else
+        return;
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get parent widget.
+     *
+     * @return parent widget
+     */
+    public final TWidget getParent() {
+        return parent;
+    }
+
+    /**
+     * Get the list of child widgets that this widget contains.
+     *
+     * @return the list of child widgets
+     */
+    public List<TWidget> getChildren() {
+        return children;
+    }
+
+    /**
+     * Remove this widget from its parent container.  close() will be called
+     * before it is removed.
+     */
+    public final void remove() {
+        remove(true);
+    }
+
+    /**
+     * Remove this widget from its parent container.
+     *
+     * @param doClose if true, call the close() method before removing the
+     * child
+     */
+    public final void remove(final boolean doClose) {
+        if (parent != null) {
+            parent.remove(this, doClose);
+        }
+    }
+
+    /**
+     * Remove a child widget from this container.
+     *
+     * @param child the child widget to remove
+     */
+    public final void remove(final TWidget child) {
+        remove(child, true);
+    }
+
+    /**
+     * Remove a child widget from this container.
+     *
+     * @param child the child widget to remove
+     * @param doClose if true, call the close() method before removing the
+     * child
+     */
+    public final void remove(final TWidget child, final boolean doClose) {
+        if (!children.contains(child)) {
+            throw new IndexOutOfBoundsException("child widget is not in " +
+                "list of children of this parent");
+        }
+        if (doClose) {
+            child.close();
+        }
+        children.remove(child);
+        child.parent = null;
+        child.window = null;
+        if (layout != null) {
+            layout.remove(this);
+        }
+    }
+
+    /**
+     * Set this widget's parent to a different widget.
+     *
+     * @param newParent new parent widget
+     * @param doClose if true, call the close() method before removing the
+     * child from its existing parent widget
+     */
+    public final void setParent(final TWidget newParent,
+        final boolean doClose) {
+
+        if (parent != null) {
+            parent.remove(this, doClose);
+            window = null;
+        }
+        assert (parent == null);
+        assert (window == null);
+        parent = newParent;
+        setWindow(parent.window);
+        parent.addChild(this);
+    }
+
+    /**
+     * Set this widget's window to a specific window.
+     *
+     * Having a null parent with a specified window is only used within Jexer
+     * by TStatusBar because TApplication routes events directly to it and
+     * calls its draw() method.  Any other non-parented widgets will require
+     * similar special case functionality to receive events or be drawn to
+     * screen.
+     *
+     * @param window the window to use
+     */
+    public final void setWindow(final TWindow window) {
+        this.window = window;
+        for (TWidget child: getChildren()) {
+            child.setWindow(window);
+        }
+    }
+
+    /**
+     * Remove a child widget from this container, and all of its children
+     * recursively from their parent containers.
+     *
+     * @param child the child widget to remove
+     * @param doClose if true, call the close() method before removing each
+     * child
+     */
+    public final void removeAll(final TWidget child, final boolean doClose) {
+        remove(child, doClose);
+        for (TWidget w: child.children) {
+            child.removeAll(w, doClose);
+        }
+    }
+
+    /**
+     * Get active flag.
+     *
+     * @return if true, this widget will receive events
+     */
+    public final boolean isActive() {
+        return active;
+    }
+
+    /**
+     * Set active flag.
+     *
+     * @param active if true, this widget will receive events
+     */
+    public final void setActive(final boolean active) {
+        this.active = active;
+    }
+
+    /**
+     * Get the window this widget is on.
+     *
+     * @return the window
+     */
+    public final TWindow getWindow() {
+        return window;
+    }
+
+    /**
+     * Get X position.
+     *
+     * @return absolute X position of the top-left corner
+     */
+    public final int getX() {
+        return x;
+    }
+
+    /**
+     * Set X position.
+     *
+     * @param x absolute X position of the top-left corner
+     */
+    public final void setX(final int x) {
+        this.x = x;
+    }
+
+    /**
+     * Get Y position.
+     *
+     * @return absolute Y position of the top-left corner
+     */
+    public final int getY() {
+        return y;
+    }
+
+    /**
+     * Set Y position.
+     *
+     * @param y absolute Y position of the top-left corner
+     */
+    public final void setY(final int y) {
+        this.y = y;
+    }
+
+    /**
+     * Get the width.
+     *
+     * @return widget width
+     */
+    public int getWidth() {
+        return this.width;
+    }
+
+    /**
+     * Change the width.
+     *
+     * @param width new widget width
+     */
+    public void setWidth(final int width) {
+        this.width = width;
+        if (layout != null) {
+            layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                    width, height));
+        }
+    }
+
+    /**
+     * Get the height.
+     *
+     * @return widget height
+     */
+    public int getHeight() {
+        return this.height;
+    }
+
+    /**
+     * Change the height.
+     *
+     * @param height new widget height
+     */
+    public void setHeight(final int height) {
+        this.height = height;
+        if (layout != null) {
+            layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                    width, height));
+        }
+    }
+
+    /**
+     * Change the dimensions.
+     *
+     * @param x absolute X position of the top-left corner
+     * @param y absolute Y position of the top-left corner
+     * @param width new widget width
+     * @param height new widget height
+     */
+    public final void setDimensions(final int x, final int y, final int width,
+        final int height) {
+
+        this.x = x;
+        this.y = y;
+        // Call the functions so that subclasses can choose how to handle it.
+        setWidth(width);
+        setHeight(height);
+        if (layout != null) {
+            layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                    width, height));
+        }
+    }
+
+    /**
+     * Get the layout manager.
+     *
+     * @return the layout manager, or null if not set
+     */
+    public LayoutManager getLayoutManager() {
+        return layout;
+    }
+
+    /**
+     * Set the layout manager.
+     *
+     * @param layout the new layout manager
+     */
+    public void setLayoutManager(LayoutManager layout) {
+        if (this.layout != null) {
+            for (TWidget w: children) {
+                this.layout.remove(w);
+            }
+            this.layout = null;
+        }
+        this.layout = layout;
+        if (this.layout != null) {
+            for (TWidget w: children) {
+                this.layout.add(w);
+            }
+        }
+    }
+
+    /**
+     * Get enabled flag.
+     *
+     * @return if true, this widget can be tabbed to or receive events
+     */
+    public final boolean isEnabled() {
+        return enabled;
+    }
+
+    /**
+     * Set enabled flag.
+     *
+     * @param enabled if true, this widget can be tabbed to or receive events
+     */
+    public final void setEnabled(final boolean enabled) {
+        this.enabled = enabled;
+        if (!enabled) {
+            active = false;
+            // See if there are any active siblings to switch to
+            boolean foundSibling = false;
+            if (parent != null) {
+                for (TWidget w: parent.children) {
+                    if ((w.enabled)
+                        && !(this instanceof THScroller)
+                        && !(this instanceof TVScroller)
+                    ) {
+                        parent.activate(w);
+                        foundSibling = true;
+                        break;
+                    }
+                }
+                if (!foundSibling) {
+                    parent.activeChild = null;
+                }
+            }
+        }
+    }
+
+    /**
+     * Set visible flag.
+     *
+     * @param visible if true, this widget will be drawn
+     */
+    public final void setVisible(final boolean visible) {
+        this.visible = visible;
+    }
+
+    /**
+     * See if this widget is visible.
+     *
+     * @return if true, this widget will be drawn
+     */
+    public final boolean isVisible() {
+        return visible;
+    }
+
+    /**
+     * Set visible cursor flag.
+     *
+     * @param cursorVisible if true, this widget has a cursor
+     */
+    public final void setCursorVisible(final boolean cursorVisible) {
+        this.cursorVisible = cursorVisible;
+    }
+
+    /**
+     * See if this widget has a visible cursor.
+     *
+     * @return if true, this widget has a visible cursor
+     */
+    public final boolean isCursorVisible() {
+        // If cursor is out of my bounds, it is not visible.
+        if ((cursorX >= width)
+            || (cursorX < 0)
+            || (cursorY >= height)
+            || (cursorY < 0)
+        ) {
+            return false;
+        }
+
+        assert (window != null);
+
+        if (window instanceof TDesktop) {
+            // Desktop doesn't have a window border.
+            return cursorVisible;
+        }
+
+        // If cursor is out of my window's bounds, it is not visible.
+        if ((getCursorAbsoluteX() >= window.getAbsoluteX()
+                + window.getWidth() - 1)
+            || (getCursorAbsoluteX() < 0)
+            || (getCursorAbsoluteY() >= window.getAbsoluteY()
+                + window.getHeight() - 1)
+            || (getCursorAbsoluteY() < 0)
+        ) {
+            return false;
+        }
+        return cursorVisible;
+    }
+
+    /**
+     * Get cursor X value.
+     *
+     * @return cursor column position in relative coordinates
+     */
+    public final int getCursorX() {
+        return cursorX;
+    }
+
+    /**
+     * Set cursor X value.
+     *
+     * @param cursorX column position in relative coordinates
+     */
+    public final void setCursorX(final int cursorX) {
+        this.cursorX = cursorX;
+    }
+
+    /**
+     * Get cursor Y value.
+     *
+     * @return cursor row position in relative coordinates
+     */
+    public final int getCursorY() {
+        return cursorY;
+    }
+
+    /**
+     * Set cursor Y value.
+     *
+     * @param cursorY row position in relative coordinates
+     */
+    public final void setCursorY(final int cursorY) {
+        this.cursorY = cursorY;
+    }
+
+    /**
+     * Get this TWidget's parent TApplication.
+     *
+     * @return the parent TApplication, or null if not assigned
+     */
+    public TApplication getApplication() {
+        if (window != null) {
+            return window.getApplication();
+        }
+        return null;
+    }
+
+    /**
+     * Get the Screen.
+     *
+     * @return the Screen, or null if not assigned
+     */
+    public Screen getScreen() {
+        if (window != null) {
+            return window.getScreen();
+        }
+        return null;
+    }
+
+    /**
+     * Get the Clipboard.
+     *
+     * @return the Clipboard, or null if not assigned
+     */
+    public Clipboard getClipboard() {
+        if (window != null) {
+            return window.getApplication().getClipboard();
+        }
+        return null;
+    }
+
+    /**
+     * Comparison operator.  For various subclasses it sorts on:
+     * <ul>
+     * <li>tabOrder for TWidgets</li>
+     * <li>z for TWindows</li>
+     * <li>text for TTreeItems</li>
+     * </ul>
+     *
+     * @param that another TWidget, TWindow, or TTreeItem instance
+     * @return difference between this.tabOrder and that.tabOrder, or
+     * difference between this.z and that.z, or String.compareTo(text)
+     */
+    @Override
+    public int compareTo(final TWidget that) {
+        if ((this instanceof TWindow)
+            && (that instanceof TWindow)
+        ) {
+            return (((TWindow) this).getZ() - ((TWindow) that).getZ());
+        }
+        if ((this instanceof TTreeItem)
+            && (that instanceof TTreeItem)
+        ) {
+            return (((TTreeItem) this).getText().compareTo(
+                ((TTreeItem) that).getText()));
+        }
+        return (this.tabOrder - that.tabOrder);
+    }
+
+    /**
+     * See if this widget should render with the active color.
+     *
+     * @return true if this widget is active and all of its parents are
+     * active.
+     */
+    public final boolean isAbsoluteActive() {
+        if (parent == this) {
+            return active;
+        }
+        return (active && (parent == null ? true : parent.isAbsoluteActive()));
+    }
+
+    /**
+     * Returns the cursor X position.
+     *
+     * @return absolute screen column number for the cursor's X position
+     */
+    public final int getCursorAbsoluteX() {
+        return getAbsoluteX() + cursorX;
+    }
+
+    /**
+     * Returns the cursor Y position.
+     *
+     * @return absolute screen row number for the cursor's Y position
+     */
+    public final int getCursorAbsoluteY() {
+        return getAbsoluteY() + cursorY;
+    }
+
+    /**
+     * Compute my absolute X position as the sum of my X plus all my parent's
+     * X's.
+     *
+     * @return absolute screen column number for my X position
+     */
+    public final int getAbsoluteX() {
+        assert (parent != null);
+        if (parent == this) {
+            return x;
+        }
+        if ((parent instanceof TWindow)
+            && !(parent instanceof TMenu)
+            && !(parent instanceof TDesktop)
+        ) {
+            // Widgets on a TWindow have (0,0) as their top-left, but this is
+            // actually the TWindow's (1,1).
+            return parent.getAbsoluteX() + x + 1;
+        }
+        return parent.getAbsoluteX() + x;
+    }
+
+    /**
+     * Compute my absolute Y position as the sum of my Y plus all my parent's
+     * Y's.
+     *
+     * @return absolute screen row number for my Y position
+     */
+    public final int getAbsoluteY() {
+        assert (parent != null);
+        if (parent == this) {
+            return y;
+        }
+        if ((parent instanceof TWindow)
+            && !(parent instanceof TMenu)
+            && !(parent instanceof TDesktop)
+        ) {
+            // Widgets on a TWindow have (0,0) as their top-left, but this is
+            // actually the TWindow's (1,1).
+            return parent.getAbsoluteY() + y + 1;
+        }
+        return parent.getAbsoluteY() + y;
+    }
+
+    /**
+     * Get the global color theme.
+     *
+     * @return the ColorTheme
+     */
+    protected final ColorTheme getTheme() {
+        return window.getApplication().getTheme();
+    }
+
+    /**
+     * See if this widget can be drawn onto a screen.
+     *
+     * @return true if this widget is part of the hierarchy that can draw to
+     * a screen
+     */
+    public final boolean isDrawable() {
+        if ((window == null)
+            || (window.getScreen() == null)
+            || (parent == null)
+        ) {
+            return false;
+        }
+        if (parent == this) {
+            return true;
+        }
+        return (parent.isDrawable());
+    }
+
+    /**
+     * Draw my specific widget.  When called, the screen rectangle I draw
+     * into is already setup (offset and clipping).
+     */
+    public void draw() {
+        // Default widget draws nothing.
+    }
+
+    /**
+     * Called by parent to render to TWindow.  Note package private access.
+     */
+    final void drawChildren() {
+        if (!isDrawable()) {
+            return;
+        }
+
+        // Set my clipping rectangle
+        assert (window != null);
+        assert (getScreen() != null);
+        Screen screen = getScreen();
+
+        // Special case: TStatusBar is drawn by TApplication, not anything
+        // else.
+        if (this instanceof TStatusBar) {
+            return;
+        }
+
+        screen.setClipRight(width);
+        screen.setClipBottom(height);
+
+        int absoluteRightEdge = window.getAbsoluteX() + window.getWidth();
+        int absoluteBottomEdge = window.getAbsoluteY() + window.getHeight();
+        if (!(this instanceof TWindow)
+            && !(this instanceof TVScroller)
+            && !(window instanceof TDesktop)
+        ) {
+            absoluteRightEdge -= 1;
+        }
+        if (!(this instanceof TWindow)
+            && !(this instanceof THScroller)
+            && !(window instanceof TDesktop)
+        ) {
+            absoluteBottomEdge -= 1;
+        }
+        int myRightEdge = getAbsoluteX() + width;
+        int myBottomEdge = getAbsoluteY() + height;
+        if (getAbsoluteX() > absoluteRightEdge) {
+            // I am offscreen
+            screen.setClipRight(0);
+        } else if (myRightEdge > absoluteRightEdge) {
+            screen.setClipRight(screen.getClipRight()
+                - (myRightEdge - absoluteRightEdge));
+        }
+        if (getAbsoluteY() > absoluteBottomEdge) {
+            // I am offscreen
+            screen.setClipBottom(0);
+        } else if (myBottomEdge > absoluteBottomEdge) {
+            screen.setClipBottom(screen.getClipBottom()
+                - (myBottomEdge - absoluteBottomEdge));
+        }
+
+        // Set my offset
+        screen.setOffsetX(getAbsoluteX());
+        screen.setOffsetY(getAbsoluteY());
+
+        // Draw me
+        draw();
+        if (!isDrawable()) {
+            // An action taken by a draw method unhooked me from the UI.
+            // Bail out.
+            return;
+        }
+
+        assert (visible == true);
+
+        // Continue down the chain.  Draw the active child last so that it
+        // is on top.
+        for (TWidget widget: children) {
+            if (widget.isVisible() && (widget != activeChild)) {
+                widget.drawChildren();
+                if (!isDrawable()) {
+                    // An action taken by a draw method unhooked me from the UI.
+                    // Bail out.
+                    return;
+                }
+            }
+        }
+        if (activeChild != null) {
+            activeChild.drawChildren();
+        }
+    }
+
+    /**
+     * Repaint the screen on the next update.
+     */
+    protected final void doRepaint() {
+        window.getApplication().doRepaint();
+    }
+
+    /**
+     * Add a child widget to my list of children.  We set its tabOrder to 0
+     * and increment the tabOrder of all other children.
+     *
+     * @param child TWidget to add
+     */
+    public void addChild(final TWidget child) {
+        children.add(child);
+
+        if ((child.enabled)
+            && !(child instanceof THScroller)
+            && !(child instanceof TVScroller)
+        ) {
+            for (TWidget widget: children) {
+                widget.active = false;
+            }
+            child.active = true;
+            activeChild = child;
+        }
+        for (int i = 0; i < children.size(); i++) {
+            children.get(i).tabOrder = i;
+        }
+        if (layout != null) {
+            layout.add(child);
+        }
+    }
+
+    /**
+     * Reset the tab order of children to match their position in the list.
+     * Available so that subclasses can re-order their widgets if needed.
+     */
+    protected void resetTabOrder() {
+        for (int i = 0; i < children.size(); i++) {
+            children.get(i).tabOrder = i;
+        }
+    }
+    
+    /**
+     * Remove and {@link TWidget#close()} the given child from this {@link TWidget}.
+     * <p>
+     * Will also reorder the tab values of the remaining children.
+     * 
+     * @param child the child to remove
+     * 
+     * @return TRUE if the child was removed, FALSE if it was not found
+     */
+    public boolean removeChild(final TWidget child) {
+        if (children.remove(child)) {
+                child.close();
+                child.parent = null;
+                child.window = null;
+                
+                resetTabOrder();
+                
+                return true;
+        }
+        
+        return false;
+    }
+
+    /**
+     * Switch the active child.
+     *
+     * @param child TWidget to activate
+     */
+    public final void activate(final TWidget child) {
+        assert (child.enabled);
+        if ((child instanceof THScroller)
+            || (child instanceof TVScroller)
+        ) {
+            return;
+        }
+
+        if (children.size() == 1) {
+            if (children.get(0).enabled == true) {
+                child.active = true;
+                activeChild = child;
+            }
+        } else {
+            if (child != activeChild) {
+                if (activeChild != null) {
+                    activeChild.active = false;
+                }
+            }
+            child.active = true;
+            activeChild = child;
+        }
+    }
+
+    /**
+     * Switch the active child.
+     *
+     * @param tabOrder tabOrder of the child to activate.  If that child
+     * isn't enabled, then the next enabled child will be activated.
+     */
+    public final void activate(final int tabOrder) {
+        if (children.size() == 1) {
+            if (children.get(0).enabled == true) {
+                children.get(0).active = true;
+                activeChild = children.get(0);
+            }
+            return;
+        }
+
+        TWidget child = null;
+        for (TWidget widget: children) {
+            if ((widget.enabled)
+                && !(widget instanceof THScroller)
+                && !(widget instanceof TVScroller)
+                && (widget.tabOrder >= tabOrder)
+            ) {
+                child = widget;
+                break;
+            }
+        }
+        if ((child != null) && (child != activeChild)) {
+            if (activeChild != null) {
+                activeChild.active = false;
+            }
+            assert (child.enabled);
+            child.active = true;
+            activeChild = child;
+        }
+    }
+
+    /**
+     * Make this widget the active child of its parent.  Note that this is
+     * not final since TWindow overrides activate().
+     */
+    public void activate() {
+        if (enabled) {
+            if (parent != null) {
+                parent.activate(this);
+            }
+        }
+    }
+
+    /**
+     * Make this widget, all of its parents, the active child.
+     */
+    public final void activateAll() {
+        activate();
+        if (parent == this) {
+            return;
+        }
+        if (parent != null) {
+            parent.activateAll();
+        }
+    }
+
+    /**
+     * Switch the active widget with the next in the tab order.
+     *
+     * @param forward if true, then switch to the next enabled widget in the
+     * list, otherwise switch to the previous enabled widget in the list
+     */
+    public final void switchWidget(final boolean forward) {
+
+        // No children: do nothing.
+        if (children.size() == 0) {
+            return;
+        }
+
+        assert (parent != null);
+
+        // If there is only one child, make it active if it is enabled.
+        if (children.size() == 1) {
+            if (children.get(0).enabled == true) {
+                activeChild = children.get(0);
+                activeChild.active = true;
+            } else {
+                children.get(0).active = false;
+                activeChild = null;
+            }
+            return;
+        }
+
+        // Two or more children: go forward or backward to the next enabled
+        // child.
+        int tabOrder = 0;
+        if (activeChild != null) {
+            tabOrder = activeChild.tabOrder;
+        }
+        do {
+            if (forward) {
+                tabOrder++;
+            } else {
+                tabOrder--;
+            }
+            if (tabOrder < 0) {
+
+                // If at the end, pass the switch to my parent.
+                if ((!forward) && (parent != this)) {
+                    parent.switchWidget(forward);
+                    return;
+                }
+
+                tabOrder = children.size() - 1;
+            } else if (tabOrder == children.size()) {
+                // If at the end, pass the switch to my parent.
+                if ((forward) && (parent != this)) {
+                    parent.switchWidget(forward);
+                    return;
+                }
+
+                tabOrder = 0;
+            }
+            if (activeChild == null) {
+                if (tabOrder == 0) {
+                    // We wrapped around
+                    break;
+                }
+            } else if (activeChild.tabOrder == tabOrder) {
+                // We wrapped around
+                break;
+            }
+        } while ((!children.get(tabOrder).enabled)
+            && !(children.get(tabOrder) instanceof THScroller)
+            && !(children.get(tabOrder) instanceof TVScroller));
+
+        if (activeChild != null) {
+            assert (children.get(tabOrder).enabled);
+
+            activeChild.active = false;
+        }
+        if (children.get(tabOrder).enabled == true) {
+            children.get(tabOrder).active = true;
+            activeChild = children.get(tabOrder);
+        }
+    }
+
+    /**
+     * Returns my active widget.
+     *
+     * @return widget that is active, or this if no children
+     */
+    public TWidget getActiveChild() {
+        if ((this instanceof THScroller)
+            || (this instanceof TVScroller)
+        ) {
+            return parent;
+        }
+
+        for (TWidget widget: children) {
+            if (widget.active) {
+                return widget.getActiveChild();
+            }
+        }
+        // No active children, return me
+        return this;
+    }
+
+    /**
+     * Insert a vertical split between this widget and parent, and optionally
+     * put another widget in the other side of the split.
+     *
+     * @param newWidgetOnLeft if true, the new widget (if specified) will be
+     * on the left pane, and this widget will be placed on the right pane
+     * @param newWidget the new widget to add to the other pane, or null
+     * @return the new split pane widget
+     */
+    public TSplitPane splitVertical(final boolean newWidgetOnLeft,
+        final TWidget newWidget) {
+
+        TSplitPane splitPane = new TSplitPane(null, x, y, width, height, true);
+        TWidget myParent = parent;
+        remove(false);
+        if (myParent instanceof TSplitPane) {
+            // TSplitPane has a left/right/top/bottom link to me somewhere,
+            // replace it with a link to splitPane.
+            ((TSplitPane) myParent).replaceWidget(this, splitPane);
+        }
+        splitPane.setParent(myParent, false);
+        if (newWidgetOnLeft) {
+            splitPane.setLeft(newWidget);
+            splitPane.setRight(this);
+        } else {
+            splitPane.setLeft(this);
+            splitPane.setRight(newWidget);
+        }
+        if (newWidget != null) {
+            newWidget.activateAll();
+        } else {
+            activateAll();
+        }
+
+        assert (parent != null);
+        assert (window != null);
+        assert (splitPane.getWindow() != null);
+        assert (splitPane.getParent() != null);
+        assert (splitPane.isActive() == true);
+        assert (parent == splitPane);
+        if (newWidget != null) {
+            assert (newWidget.parent == parent);
+            assert (newWidget.active == true);
+            assert (active == false);
+        } else {
+            assert (active == true);
+        }
+        return splitPane;
+    }
+
+    /**
+     * Insert a horizontal split between this widget and parent, and
+     * optionally put another widget in the other side of the split.
+     *
+     * @param newWidgetOnTop if true, the new widget (if specified) will be
+     * on the top pane, and this widget's children will be placed on the
+     * bottom pane
+     * @param newWidget the new widget to add to the other pane, or null
+     * @return the new split pane widget
+     */
+    public TSplitPane splitHorizontal(final boolean newWidgetOnTop,
+        final TWidget newWidget) {
+
+        TSplitPane splitPane = new TSplitPane(null, x, y, width, height, false);
+        TWidget myParent = parent;
+        remove(false);
+        if (myParent instanceof TSplitPane) {
+            // TSplitPane has a left/right/top/bottom link to me somewhere,
+            // replace it with a link to splitPane.
+            ((TSplitPane) myParent).replaceWidget(this, splitPane);
+        }
+        splitPane.setParent(myParent, false);
+        if (newWidgetOnTop) {
+            splitPane.setTop(newWidget);
+            splitPane.setBottom(this);
+        } else {
+            splitPane.setTop(this);
+            splitPane.setBottom(newWidget);
+        }
+        if (newWidget != null) {
+            newWidget.activateAll();
+        } else {
+            activateAll();
+        }
+
+        assert (parent != null);
+        assert (window != null);
+        assert (splitPane.getWindow() != null);
+        assert (splitPane.getParent() != null);
+        assert (splitPane.isActive() == true);
+        assert (parent == splitPane);
+        if (newWidget != null) {
+            assert (newWidget.parent == parent);
+            assert (newWidget.active == true);
+            assert (active == false);
+        } else {
+            assert (active == true);
+        }
+        return splitPane;
+    }
+
+    /**
+     * Generate a human-readable string for this widget.
+     *
+     * @return a human-readable string
+     */
+    @Override
+    public String toString() {
+        return String.format("%s(%8x) position (%d, %d) geometry %dx%d " +
+            "active %s enabled %s visible %s", getClass().getName(),
+            hashCode(), x, y, width, height, active, enabled, visible);
+    }
+
+    /**
+     * Generate a string for this widget's hierarchy.
+     *
+     * @param prefix a prefix to use for this widget's place in the hierarchy
+     * @return a pretty-printable string of this hierarchy
+     */
+    protected String toPrettyString(final String prefix) {
+        StringBuilder sb = new StringBuilder(prefix);
+        sb.append(toString());
+        String newPrefix = "";
+        for (int i = 0; i < prefix.length(); i++) {
+            newPrefix += " ";
+        }
+        for (int i = 0; i < children.size(); i++) {
+            TWidget child= children.get(i);
+            sb.append("\n");
+            if (i == children.size() - 1) {
+                sb.append(child.toPrettyString(newPrefix + " \u2514\u2500"));
+            } else {
+                sb.append(child.toPrettyString(newPrefix + " \u251c\u2500"));
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Generate a string for this widget's hierarchy.
+     *
+     * @return a pretty-printable string of this hierarchy
+     */
+    public String toPrettyString() {
+        return toPrettyString("");
+    }
+
+    // ------------------------------------------------------------------------
+    // Passthru for Screen functions ------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return attributes at (x, y)
+     */
+    protected final CellAttributes getAttrXY(final int x, final int y) {
+        return getScreen().getAttrXY(x, y);
+    }
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    protected final void putAttrXY(final int x, final int y,
+        final CellAttributes attr) {
+
+        getScreen().putAttrXY(x, y, attr);
+    }
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     * @param clip if true, honor clipping/offset
+     */
+    protected final void putAttrXY(final int x, final int y,
+        final CellAttributes attr, final boolean clip) {
+
+        getScreen().putAttrXY(x, y, attr, clip);
+    }
+
+    /**
+     * Fill the entire screen with one character with attributes.
+     *
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    protected final void putAll(final int ch, final CellAttributes attr) {
+        getScreen().putAll(ch, attr);
+    }
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character + attributes to draw
+     */
+    protected final void putCharXY(final int x, final int y, final Cell ch) {
+        getScreen().putCharXY(x, y, ch);
+    }
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    protected final void putCharXY(final int x, final int y, final int ch,
+        final CellAttributes attr) {
+
+        getScreen().putCharXY(x, y, ch, attr);
+    }
+
+    /**
+     * Render one character without changing the underlying attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     */
+    protected final void putCharXY(final int x, final int y, final int ch) {
+        getScreen().putCharXY(x, y, ch);
+    }
+
+    /**
+     * Render a string.  Does not wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    protected final void putStringXY(final int x, final int y, final String str,
+        final CellAttributes attr) {
+
+        getScreen().putStringXY(x, y, str, attr);
+    }
+
+    /**
+     * Render a string without changing the underlying attribute.  Does not
+     * wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     */
+    protected final void putStringXY(final int x, final int y, final String str) {
+        getScreen().putStringXY(x, y, str);
+    }
+
+    /**
+     * Draw a vertical line from (x, y) to (x, y + n).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    protected final void vLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr) {
+
+        getScreen().vLineXY(x, y, n, ch, attr);
+    }
+
+    /**
+     * Draw a horizontal line from (x, y) to (x + n, y).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    protected final void hLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr) {
+
+        getScreen().hLineXY(x, y, n, ch, attr);
+    }
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     */
+    protected final void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background) {
+
+        getScreen().drawBox(left, top, right, bottom, border, background);
+    }
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     * @param borderType if 1, draw a single-line border; if 2, draw a
+     * double-line border; if 3, draw double-line top/bottom edges and
+     * single-line left/right edges (like Qmodem)
+     * @param shadow if true, draw a "shadow" on the box
+     */
+    protected final void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background,
+        final int borderType, final boolean shadow) {
+
+        getScreen().drawBox(left, top, right, bottom, border, background,
+            borderType, shadow);
+    }
+
+    /**
+     * Draw a box shadow.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     */
+    protected final void drawBoxShadow(final int left, final int top,
+        final int right, final int bottom) {
+
+        getScreen().drawBoxShadow(left, top, right, bottom);
+    }
+
+    // ------------------------------------------------------------------------
+    // Other TWidget constructors ---------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Convenience function to add a label to this container/window.
+     *
+     * @param text label
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @return the new label
+     */
+    public final TLabel addLabel(final String text, final int x, final int y) {
+        return addLabel(text, x, y, "tlabel");
+    }
+
+    /**
+     * Convenience function to add a label to this container/window.
+     *
+     * @param text label
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param action to call when shortcut is pressed
+     * @return the new label
+     */
+    public final TLabel addLabel(final String text, final int x, final int y,
+        final TAction action) {
+
+        return addLabel(text, x, y, "tlabel", action);
+    }
+
+    /**
+     * Convenience function to add a label to this container/window.
+     *
+     * @param text label
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text.
+     * Default is "tlabel"
+     * @return the new label
+     */
+    public final TLabel addLabel(final String text, final int x, final int y,
+        final String colorKey) {
+
+        return new TLabel(this, text, x, y, colorKey);
+    }
+
+    /**
+     * Convenience function to add a label to this container/window.
+     *
+     * @param text label
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text.
+     * Default is "tlabel"
+     * @param action to call when shortcut is pressed
+     * @return the new label
+     */
+    public final TLabel addLabel(final String text, final int x, final int y,
+        final String colorKey, final TAction action) {
+
+        return new TLabel(this, text, x, y, colorKey, action);
+    }
+
+    /**
+     * Convenience function to add a label to this container/window.
+     *
+     * @param text label
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text.
+     * Default is "tlabel"
+     * @param useWindowBackground if true, use the window's background color
+     * @return the new label
+     */
+    public final TLabel addLabel(final String text, final int x, final int y,
+        final String colorKey, final boolean useWindowBackground) {
+
+        return new TLabel(this, text, x, y, colorKey, useWindowBackground);
+    }
+
+    /**
+     * Convenience function to add a label to this container/window.
+     *
+     * @param text label
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param colorKey ColorTheme key color to use for foreground text.
+     * Default is "tlabel"
+     * @param useWindowBackground if true, use the window's background color
+     * @param action to call when shortcut is pressed
+     * @return the new label
+     */
+    public final TLabel addLabel(final String text, final int x, final int y,
+        final String colorKey, final boolean useWindowBackground,
+        final TAction action) {
+
+        return new TLabel(this, text, x, y, colorKey, useWindowBackground,
+            action);
+    }
+
+    /**
+     * Convenience function to add a button to this container/window.
+     *
+     * @param text label on the button
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param action action to call when button is pressed
+     * @return the new button
+     */
+    public final TButton addButton(final String text, final int x, final int y,
+        final TAction action) {
+
+        return new TButton(this, text, x, y, action);
+    }
+
+    /**
+     * Convenience function to add a checkbox to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label label to display next to (right of) the checkbox
+     * @param checked initial check state
+     * @return the new checkbox
+     */
+    public final TCheckBox addCheckBox(final int x, final int y,
+        final String label, final boolean checked) {
+
+        return new TCheckBox(this, x, y, label, checked);
+    }
+
+    /**
+     * Convenience function to add a combobox to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible combobox width, including the down-arrow
+     * @param values the possible values for the box, shown in the drop-down
+     * @param valuesIndex the initial index in values, or -1 for no default
+     * value
+     * @param maxValuesHeight the maximum height of the values drop-down when
+     * it is visible
+     * @param updateAction action to call when a new value is selected from
+     * the list or enter is pressed in the edit field
+     * @return the new combobox
+     */
+    public final TComboBox addComboBox(final int x, final int y,
+        final int width, final List<String> values, final int valuesIndex,
+        final int maxValuesHeight, final TAction updateAction) {
+
+        return new TComboBox(this, x, y, width, values, valuesIndex,
+            maxValuesHeight, updateAction);
+    }
+
+    /**
+     * Convenience function to add a spinner to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param upAction action to call when the up arrow is clicked or pressed
+     * @param downAction action to call when the down arrow is clicked or
+     * pressed
+     * @return the new spinner
+     */
+    public final TSpinner addSpinner(final int x, final int y,
+        final TAction upAction, final TAction downAction) {
+
+        return new TSpinner(this, x, y, upAction, downAction);
+    }
+
+    /**
+     * Convenience function to add a calendar to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param updateAction action to call when the user changes the value of
+     * the calendar
+     * @return the new calendar
+     */
+    public final TCalendar addCalendar(final int x, final int y,
+        final TAction updateAction) {
+
+        return new TCalendar(this, x, y, updateAction);
+    }
+
+    /**
+     * Convenience function to add a progress bar to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of progress bar
+     * @param value initial value of percent complete
+     * @return the new progress bar
+     */
+    public final TProgressBar addProgressBar(final int x, final int y,
+        final int width, final int value) {
+
+        return new TProgressBar(this, x, y, width, value);
+    }
+
+    /**
+     * Convenience function to add a radio button group to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label label to display on the group box
+     * @return the new radio button group
+     */
+    public final TRadioGroup addRadioGroup(final int x, final int y,
+        final String label) {
+
+        return new TRadioGroup(this, x, y, label);
+    }
+
+    /**
+     * Convenience function to add a radio button group to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of group
+     * @param label label to display on the group box
+     */
+    public final TRadioGroup addRadioGroup(final int x, final int y,
+        final int width, final String label) {
+
+        return new TRadioGroup(this, x, y, width, label);
+    }
+
+    /**
+     * Convenience function to add a text field to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @return the new text field
+     */
+    public final TField addField(final int x, final int y,
+        final int width, final boolean fixed) {
+
+        return new TField(this, x, y, width, fixed);
+    }
+
+    /**
+     * Convenience function to add a text field to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     * @return the new text field
+     */
+    public final TField addField(final int x, final int y,
+        final int width, final boolean fixed, final String text) {
+
+        return new TField(this, x, y, width, fixed, text);
+    }
+
+    /**
+     * Convenience function to add a text field to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     * @param enterAction function to call when enter key is pressed
+     * @param updateAction function to call when the text is updated
+     * @return the new text field
+     */
+    public final TField addField(final int x, final int y,
+        final int width, final boolean fixed, final String text,
+        final TAction enterAction, final TAction updateAction) {
+
+        return new TField(this, x, y, width, fixed, text, enterAction,
+            updateAction);
+    }
+
+    /**
+     * Convenience function to add a scrollable text box to this
+     * container/window.
+     *
+     * @param text text on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param colorKey ColorTheme key color to use for foreground text
+     * @return the new text box
+     */
+    public final TText addText(final String text, final int x,
+        final int y, final int width, final int height, final String colorKey) {
+
+        return new TText(this, text, x, y, width, height, colorKey);
+    }
+
+    /**
+     * Convenience function to add a scrollable text box to this
+     * container/window.
+     *
+     * @param text text on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @return the new text box
+     */
+    public final TText addText(final String text, final int x, final int y,
+        final int width, final int height) {
+
+        return new TText(this, text, x, y, width, height, "ttext");
+    }
+
+    /**
+     * Convenience function to add an editable text area box to this
+     * container/window.
+     *
+     * @param text text on the screen
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @return the new text box
+     */
+    public final TEditorWidget addEditor(final String text, final int x,
+        final int y, final int width, final int height) {
+
+        return new TEditorWidget(this, text, x, y, width, height);
+    }
+
+    /**
+     * Convenience function to spawn a message box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @return the new message box
+     */
+    public final TMessageBox messageBox(final String title,
+        final String caption) {
+
+        return getApplication().messageBox(title, caption, TMessageBox.Type.OK);
+    }
+
+    /**
+     * Convenience function to spawn a message box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param type one of the TMessageBox.Type constants.  Default is
+     * Type.OK.
+     * @return the new message box
+     */
+    public final TMessageBox messageBox(final String title,
+        final String caption, final TMessageBox.Type type) {
+
+        return getApplication().messageBox(title, caption, type);
+    }
+
+    /**
+     * Convenience function to spawn an input box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @return the new input box
+     */
+    public final TInputBox inputBox(final String title, final String caption) {
+
+        return getApplication().inputBox(title, caption);
+    }
+
+    /**
+     * Convenience function to spawn an input box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param text initial text to seed the field with
+     * @return the new input box
+     */
+    public final TInputBox inputBox(final String title, final String caption,
+        final String text) {
+
+        return getApplication().inputBox(title, caption, text);
+    }
+
+    /**
+     * Convenience function to spawn an input box.
+     *
+     * @param title window title, will be centered along the top border
+     * @param caption message to display.  Use embedded newlines to get a
+     * multi-line box.
+     * @param text initial text to seed the field with
+     * @param type one of the Type constants.  Default is Type.OK.
+     * @return the new input box
+     */
+    public final TInputBox inputBox(final String title, final String caption,
+        final String text, final TInputBox.Type type) {
+
+        return getApplication().inputBox(title, caption, text, type);
+    }
+
+    /**
+     * Convenience function to add a password text field to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @return the new text field
+     */
+    public final TPasswordField addPasswordField(final int x, final int y,
+        final int width, final boolean fixed) {
+
+        return new TPasswordField(this, x, y, width, fixed);
+    }
+
+    /**
+     * Convenience function to add a password text field to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     * @return the new text field
+     */
+    public final TPasswordField addPasswordField(final int x, final int y,
+        final int width, final boolean fixed, final String text) {
+
+        return new TPasswordField(this, x, y, width, fixed, text);
+    }
+
+    /**
+     * Convenience function to add a password text field to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width visible text width
+     * @param fixed if true, the text cannot exceed the display width
+     * @param text initial text, default is empty string
+     * @param enterAction function to call when enter key is pressed
+     * @param updateAction function to call when the text is updated
+     * @return the new text field
+     */
+    public final TPasswordField addPasswordField(final int x, final int y,
+        final int width, final boolean fixed, final String text,
+        final TAction enterAction, final TAction updateAction) {
+
+        return new TPasswordField(this, x, y, width, fixed, text, enterAction,
+            updateAction);
+    }
+
+    /**
+     * Convenience function to add a scrollable tree view to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     * @return the new tree view
+     */
+    public final TTreeViewWidget addTreeViewWidget(final int x, final int y,
+        final int width, final int height) {
+
+        return new TTreeViewWidget(this, x, y, width, height);
+    }
+
+    /**
+     * Convenience function to add a scrollable tree view to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     * @param action action to perform when an item is selected
+     * @return the new tree view
+     */
+    public final TTreeViewWidget addTreeViewWidget(final int x, final int y,
+        final int width, final int height, final TAction action) {
+
+        return new TTreeViewWidget(this, x, y, width, height, action);
+    }
+
+    /**
+     * Convenience function to spawn a file open box.
+     *
+     * @param path path of selected file
+     * @return the result of the new file open box
+     * @throws IOException if a java.io operation throws
+     */
+    public final String fileOpenBox(final String path) throws IOException {
+        return getApplication().fileOpenBox(path);
+    }
+
+    /**
+     * Convenience function to spawn a file save box.
+     *
+     * @param path path of selected file
+     * @return the result of the new file open box
+     * @throws IOException if a java.io operation throws
+     */
+    public final String fileSaveBox(final String path) throws IOException {
+        return getApplication().fileOpenBox(path, TFileOpenBox.Type.SAVE);
+    }
+
+    /**
+     * Convenience function to spawn a file open box.
+     *
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @return the result of the new file open box
+     * @throws IOException if a java.io operation throws
+     */
+    public final String fileOpenBox(final String path,
+        final TFileOpenBox.Type type) throws IOException {
+
+        return getApplication().fileOpenBox(path, type);
+    }
+
+    /**
+     * Convenience function to spawn a file open box.
+     *
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @param filter a string that files must match to be displayed
+     * @return the result of the new file open box
+     * @throws IOException of a java.io operation throws
+     */
+    public final String fileOpenBox(final String path,
+        final TFileOpenBox.Type type, final String filter) throws IOException {
+
+        ArrayList<String> filters = new ArrayList<String>();
+        filters.add(filter);
+
+        return getApplication().fileOpenBox(path, type, filters);
+    }
+
+    /**
+     * Convenience function to spawn a file open box.
+     *
+     * @param path path of selected file
+     * @param type one of the Type constants
+     * @param filters a list of strings that files must match to be displayed
+     * @return the result of the new file open box
+     * @throws IOException of a java.io operation throws
+     */
+    public final String fileOpenBox(final String path,
+        final TFileOpenBox.Type type,
+        final List<String> filters) throws IOException {
+
+        return getApplication().fileOpenBox(path, type, filters);
+    }
+
+    /**
+     * Convenience function to add a directory list to this container/window.
+     *
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @return the new directory list
+     */
+    public final TDirectoryList addDirectoryList(final String path, final int x,
+        final int y, final int width, final int height) {
+
+        return new TDirectoryList(this, path, x, y, width, height, null);
+    }
+
+    /**
+     * Convenience function to add a directory list to this container/window.
+     *
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param action action to perform when an item is selected (enter or
+     * double-click)
+     * @return the new directory list
+     */
+    public final TDirectoryList addDirectoryList(final String path, final int x,
+        final int y, final int width, final int height, final TAction action) {
+
+        return new TDirectoryList(this, path, x, y, width, height, action);
+    }
+
+    /**
+     * Convenience function to add a directory list to this container/window.
+     *
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param action action to perform when an item is selected (enter or
+     * double-click)
+     * @param singleClickAction action to perform when an item is selected
+     * (single-click)
+     * @return the new directory list
+     */
+    public final TDirectoryList addDirectoryList(final String path, final int x,
+        final int y, final int width, final int height, final TAction action,
+        final TAction singleClickAction) {
+
+        return new TDirectoryList(this, path, x, y, width, height, action,
+            singleClickAction);
+    }
+
+    /**
+     * Convenience function to add a directory list to this container/window.
+     *
+     * @param path directory path, must be a directory
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param action action to perform when an item is selected (enter or
+     * double-click)
+     * @param singleClickAction action to perform when an item is selected
+     * (single-click)
+     * @param filters a list of strings that files must match to be displayed
+     * @return the new directory list
+     */
+    public final TDirectoryList addDirectoryList(final String path, final int x,
+        final int y, final int width, final int height, final TAction action,
+        final TAction singleClickAction, final List<String> filters) {
+
+        return new TDirectoryList(this, path, x, y, width, height, action,
+            singleClickAction, filters);
+    }
+
+    /**
+     * Convenience function to add a list to this container/window.
+     *
+     * @param strings list of strings to show
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @return the new directory list
+     */
+    public final TList addList(final List<String> strings, final int x,
+        final int y, final int width, final int height) {
+
+        return new TList(this, strings, x, y, width, height, null);
+    }
+
+    /**
+     * Convenience function to add a list to this container/window.
+     *
+     * @param strings list of strings to show
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param enterAction action to perform when an item is selected
+     * @return the new directory list
+     */
+    public final TList addList(final List<String> strings, final int x,
+        final int y, final int width, final int height,
+        final TAction enterAction) {
+
+        return new TList(this, strings, x, y, width, height, enterAction);
+    }
+
+    /**
+     * Convenience function to add a list to this container/window.
+     *
+     * @param strings list of strings to show
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param enterAction action to perform when an item is selected
+     * @param moveAction action to perform when the user navigates to a new
+     * item with arrow/page keys
+     * @return the new directory list
+     */
+    public final TList addList(final List<String> strings, final int x,
+        final int y, final int width, final int height,
+        final TAction enterAction, final TAction moveAction) {
+
+        return new TList(this, strings, x, y, width, height, enterAction,
+            moveAction);
+    }
+
+    /**
+     * Convenience function to add a list to this container/window.
+     *
+     * @param strings list of strings to show.  This is allowed to be null
+     * and set later with setList() or by subclasses.
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param enterAction action to perform when an item is selected
+     * @param moveAction action to perform when the user navigates to a new
+     * item with arrow/page keys
+     * @param singleClickAction action to perform when the user clicks on an
+     * item
+     */
+    public TList addList(final List<String> strings, final int x,
+        final int y, final int width, final int height,
+        final TAction enterAction, final TAction moveAction,
+        final TAction singleClickAction) {
+
+        return new TList(this, strings, x, y, width, height, enterAction,
+            moveAction, singleClickAction);
+    }
+
+
+    /**
+     * Convenience function to add an image to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width number of text cells for width of the image
+     * @param height number of text cells for height of the image
+     * @param image the image to display
+     * @param left left column of the image.  0 is the left-most column.
+     * @param top top row of the image.  0 is the top-most row.
+     */
+    public final TImage addImage(final int x, final int y,
+        final int width, final int height,
+        final BufferedImage image, final int left, final int top) {
+
+        return new TImage(this, x, y, width, height, image, left, top);
+    }
+
+    /**
+     * Convenience function to add an image to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width number of text cells for width of the image
+     * @param height number of text cells for height of the image
+     * @param image the image to display
+     * @param left left column of the image.  0 is the left-most column.
+     * @param top top row of the image.  0 is the top-most row.
+     * @param clickAction function to call when mouse is pressed
+     */
+    public final TImage addImage(final int x, final int y,
+        final int width, final int height,
+        final BufferedImage image, final int left, final int top,
+        final TAction clickAction) {
+
+        return new TImage(this, x, y, width, height, image, left, top,
+            clickAction);
+    }
+
+    /**
+     * Convenience function to add an editable 2D data table to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     */
+    public TTableWidget addTable(final int x, final int y, final int width,
+        final int height) {
+
+        return new TTableWidget(this, x, y, width, height);
+    }
+
+    /**
+     * Convenience function to add an editable 2D data table to this
+     * container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of widget
+     * @param height height of widget
+     * @param gridColumns number of columns in grid
+     * @param gridRows number of rows in grid
+     */
+    public TTableWidget addTable(final int x, final int y, final int width,
+        final int height, final int gridColumns, final int gridRows) {
+
+        return new TTableWidget(this, x, y, width, height, gridColumns,
+            gridRows);
+    }
+
+    /**
+     * Convenience function to add a panel to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @return the new panel
+     */
+    public final TPanel addPanel(final int x, final int y, final int width,
+        final int height) {
+
+        return new TPanel(this, x, y, width, height);
+    }
+
+    /**
+     * Convenience function to add a split pane to this container/window.
+     *
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of text area
+     * @param height height of text area
+     * @param vertical if true, split vertically
+     * @return the new split pane
+     */
+    public final TSplitPane addSplitPane(final int x, final int y,
+        final int width, final int height, final boolean vertical) {
+
+        return new TSplitPane(this, x, y, width, height, vertical);
+    }
+
+}
diff --git a/src/jexer/TWindow.java b/src/jexer/TWindow.java
new file mode 100644 (file)
index 0000000..4d14d0e
--- /dev/null
@@ -0,0 +1,1463 @@
+/*
+ * 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.HashSet;
+import java.util.Set;
+
+import jexer.backend.Screen;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMenuEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import jexer.menu.TMenu;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * TWindow is the top-level container and drawing surface for other widgets.
+ */
+public class TWindow extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Window is resizable (default yes).
+     */
+    public static final int RESIZABLE   = 0x01;
+
+    /**
+     * Window is modal (default no).
+     */
+    public static final int MODAL       = 0x02;
+
+    /**
+     * Window is centered (default no).
+     */
+    public static final int CENTERED    = 0x04;
+
+    /**
+     * Window has no close box (default no).  Window can still be closed via
+     * TApplication.closeWindow() and TWindow.close().
+     */
+    public static final int NOCLOSEBOX  = 0x08;
+
+    /**
+     * Window has no maximize box (default no).
+     */
+    public static final int NOZOOMBOX   = 0x10;
+
+    /**
+     * Window is placed at absolute position (no smart placement) (default
+     * no).
+     */
+    public static final int ABSOLUTEXY  = 0x20;
+
+    /**
+     * Hitting the closebox with the mouse calls TApplication.hideWindow()
+     * rather than TApplication.closeWindow() (default no).
+     */
+    public static final int HIDEONCLOSE = 0x40;
+
+    /**
+     * Menus cannot be used when this window is active (default no).
+     */
+    public static final int OVERRIDEMENU        = 0x80;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Window flags.  Note package private access.
+     */
+    int flags = RESIZABLE;
+
+    /**
+     * Window title.
+     */
+    private String title = "";
+
+    /**
+     * Window's parent TApplication.
+     */
+    private TApplication application;
+
+    /**
+     * Z order.  Lower number means more in-front.
+     */
+    private int z = 0;
+
+    /**
+     * Window's keyboard shortcuts.  Any key in this set will be passed to
+     * the window directly rather than processed through the menu
+     * accelerators.
+     */
+    private Set<TKeypress> keyboardShortcuts = new HashSet<TKeypress>();
+
+    /**
+     * If true, then the user clicked on the title bar and is moving the
+     * window.
+     */
+    protected boolean inWindowMove = false;
+
+    /**
+     * If true, then the user clicked on the bottom right corner and is
+     * resizing the window.
+     */
+    protected boolean inWindowResize = false;
+
+    /**
+     * If true, then the user selected "Size/Move" (or hit Ctrl-F5) and is
+     * resizing/moving the window via the keyboard.
+     */
+    protected boolean inKeyboardResize = false;
+
+    /**
+     * If true, this window is maximized.
+     */
+    private boolean maximized = false;
+
+    /**
+     * Remember mouse state.
+     */
+    protected TMouseEvent mouse;
+
+    // For moving the window.  resizing also uses moveWindowMouseX/Y
+    private int moveWindowMouseX;
+    private int moveWindowMouseY;
+    private int oldWindowX;
+    private int oldWindowY;
+
+    // Resizing
+    private int resizeWindowWidth;
+    private int resizeWindowHeight;
+    private int minimumWindowWidth = 10;
+    private int minimumWindowHeight = 2;
+    private int maximumWindowWidth = -1;
+    private int maximumWindowHeight = -1;
+
+    // For maximize/restore
+    private int restoreWindowWidth;
+    private int restoreWindowHeight;
+    private int restoreWindowX;
+    private int restoreWindowY;
+
+    /**
+     * Hidden flag.  A hidden window will still have its onIdle() called, and
+     * will also have onClose() called at application exit.  Note package
+     * private access: TApplication will force hidden false if a modal window
+     * is active.
+     */
+    boolean hidden = false;
+
+    /**
+     * A window may have a status bar associated with it.  TApplication will
+     * draw this status bar last, and will also route events to it first
+     * before the window.
+     */
+    protected TStatusBar statusBar = null;
+
+    /**
+     * A window may request that TApplication NOT draw the mouse cursor over
+     * it by setting this to true.  This is currently only used within Jexer
+     * by TTerminalWindow so that only the bottom-most instance of nested
+     * Jexer's draws the mouse within its application window.  But perhaps
+     * other applications can use it, so public getter/setter is provided.
+     */
+    private boolean hideMouse = false;
+
+    /**
+     * The help topic for this window.
+     */
+    protected String helpTopic = "Help";
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  Window will be located at (0, 0).
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     */
+    public TWindow(final TApplication application, final String title,
+        final int width, final int height) {
+
+        this(application, title, 0, 0, width, height, RESIZABLE);
+    }
+
+    /**
+     * Public constructor.  Window will be located at (0, 0).
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+     */
+    public TWindow(final TApplication application, final String title,
+        final int width, final int height, final int flags) {
+
+        this(application, title, 0, 0, width, height, flags);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     */
+    public TWindow(final TApplication application, final String title,
+        final int x, final int y, final int width, final int height) {
+
+        this(application, title, x, y, width, height, RESIZABLE);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     * @param flags mask of RESIZABLE, CENTERED, or MODAL
+     */
+    public TWindow(final TApplication application, final String title,
+        final int x, final int y, final int width, final int height,
+        final int flags) {
+
+        super();
+
+        // I am my own window and parent
+        setupForTWindow(this, x, y + application.getDesktopTop(),
+            width, height);
+
+        // Save fields
+        this.title       = title;
+        this.application = application;
+        this.flags       = flags;
+
+        // Minimum width/height are 10 and 2
+        assert (width >= 10);
+        assert (getHeight() >= 2);
+
+        // MODAL implies CENTERED
+        if (isModal()) {
+            this.flags |= CENTERED;
+        }
+
+        // Center window if specified
+        center();
+
+        // Add me to the application
+        application.addWindowToApplication(this);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the close button.
+     *
+     * @return true if mouse is currently on the close button
+     */
+    protected boolean mouseOnClose() {
+        if ((flags & NOCLOSEBOX) != 0) {
+            return false;
+        }
+        if ((mouse != null)
+            && (mouse.getAbsoluteY() == getY())
+            && (mouse.getAbsoluteX() == getX() + 3)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the maximize/restore button.
+     *
+     * @return true if the mouse is currently on the maximize/restore button
+     */
+    protected boolean mouseOnMaximize() {
+        if ((flags & NOZOOMBOX) != 0) {
+            return false;
+        }
+        if ((mouse != null)
+            && !isModal()
+            && (mouse.getAbsoluteY() == getY())
+            && (mouse.getAbsoluteX() == getX() + getWidth() - 4)
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the mouse is currently on the resizable lower right
+     * corner.
+     *
+     * @return true if the mouse is currently on the resizable lower right
+     * corner
+     */
+    protected boolean mouseOnResize() {
+        if (((flags & RESIZABLE) != 0)
+            && !isModal()
+            && (mouse != null)
+            && (mouse.getAbsoluteY() == getY() + getHeight() - 1)
+            && ((mouse.getAbsoluteX() == getX() + getWidth() - 1)
+                || (mouse.getAbsoluteX() == getX() + getWidth() - 2))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Subclasses should override this method to perform any user prompting
+     * before they are offscreen.  Note that unlike other windowing toolkits,
+     * windows can NOT use this function in some manner to avoid being
+     * closed.  This is called by application.closeWindow().
+     */
+    protected void onPreClose() {
+        // Default: do nothing.
+    }
+
+    /**
+     * Subclasses should override this method to cleanup resources.  This is
+     * called by application.closeWindow().
+     */
+    protected void onClose() {
+        // Default: perform widget-specific cleanup.
+        for (TWidget w: getChildren()) {
+            w.close();
+        }
+    }
+
+    /**
+     * Called by application.switchWindow() when this window gets the
+     * focus, and also by application.addWindow().
+     */
+    protected void onFocus() {
+        // Default: do nothing
+    }
+
+    /**
+     * Called by application.switchWindow() when another window gets the
+     * focus.
+     */
+    protected void onUnfocus() {
+        // Default: do nothing
+    }
+
+    /**
+     * Called by application.hideWindow().
+     */
+    protected void onHide() {
+        // Default: do nothing
+    }
+
+    /**
+     * Called by application.showWindow().
+     */
+    protected void onShow() {
+        // Default: do nothing
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        inKeyboardResize = false;
+        inWindowMove = false;
+        inWindowResize = false;
+
+        if ((mouse.getAbsoluteY() == getY())
+            && mouse.isMouse1()
+            && (getX() <= mouse.getAbsoluteX())
+            && (mouse.getAbsoluteX() < getX() + getWidth())
+            && !mouseOnClose()
+            && !mouseOnMaximize()
+        ) {
+            // Begin moving window
+            inWindowMove = true;
+            moveWindowMouseX = mouse.getAbsoluteX();
+            moveWindowMouseY = mouse.getAbsoluteY();
+            oldWindowX = getX();
+            oldWindowY = getY();
+            if (maximized) {
+                maximized = false;
+            }
+            return;
+        }
+        if (mouseOnResize()) {
+            // Begin window resize
+            inWindowResize = true;
+            moveWindowMouseX = mouse.getAbsoluteX();
+            moveWindowMouseY = mouse.getAbsoluteY();
+            resizeWindowWidth = getWidth();
+            resizeWindowHeight = getHeight();
+            if (maximized) {
+                maximized = false;
+            }
+            return;
+        }
+
+        // Give the shortcut bar a shot at this.
+        if (statusBar != null) {
+            if (statusBar.statusBarMouseDown(mouse)) {
+                return;
+            }
+        }
+
+        // I didn't take it, pass it on to my children
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if ((inWindowMove) && (mouse.isMouse1())) {
+            // Stop moving window
+            inWindowMove = false;
+            return;
+        }
+
+        if ((inWindowResize) && (mouse.isMouse1())) {
+            // Stop resizing window
+            inWindowResize = false;
+            return;
+        }
+
+        if (mouse.isMouse1() && mouseOnClose()) {
+            if ((flags & HIDEONCLOSE) == 0) {
+                // Close window
+                application.closeWindow(this);
+            } else {
+                // Hide window
+                application.hideWindow(this);
+            }
+            return;
+        }
+
+        if ((mouse.getAbsoluteY() == getY())
+            && mouse.isMouse1()
+            && mouseOnMaximize()) {
+            if (maximized) {
+                // Restore
+                restore();
+            } else {
+                // Maximize
+                maximize();
+            }
+            // Pass a resize event to my children
+            onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                    getWidth(), getHeight()));
+            return;
+        }
+
+        // Give the shortcut bar a shot at this.
+        if (statusBar != null) {
+            if (statusBar.statusBarMouseUp(mouse)) {
+                return;
+            }
+        }
+
+        // I didn't take it, pass it on to my children
+        super.onMouseUp(mouse);
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        if (inWindowMove) {
+            // Move window over
+            setX(oldWindowX + (mouse.getAbsoluteX() - moveWindowMouseX));
+            setY(oldWindowY + (mouse.getAbsoluteY() - moveWindowMouseY));
+            // Don't cover up the menu bar
+            if (getY() < application.getDesktopTop()) {
+                setY(application.getDesktopTop());
+            }
+            // Don't go below the status bar
+            if (getY() >= application.getDesktopBottom()) {
+                setY(application.getDesktopBottom() - 1);
+            }
+            return;
+        }
+
+        if (inWindowResize) {
+            // Move window over
+            setWidth(resizeWindowWidth + (mouse.getAbsoluteX()
+                    - moveWindowMouseX));
+            setHeight(resizeWindowHeight + (mouse.getAbsoluteY()
+                    - moveWindowMouseY));
+            if (getX() + getWidth() > getScreen().getWidth()) {
+                setWidth(getScreen().getWidth() - getX());
+            }
+            if (getY() + getHeight() > application.getDesktopBottom()) {
+                setY(application.getDesktopBottom() - getHeight() + 1);
+            }
+            // Don't cover up the menu bar
+            if (getY() < application.getDesktopTop()) {
+                setY(application.getDesktopTop());
+            }
+
+            // Keep within min/max bounds
+            if (getWidth() < minimumWindowWidth) {
+                setWidth(minimumWindowWidth);
+            }
+            if (getHeight() < minimumWindowHeight) {
+                setHeight(minimumWindowHeight);
+            }
+            if ((maximumWindowWidth > 0)
+                && (getWidth() > maximumWindowWidth)
+            ) {
+                setWidth(maximumWindowWidth);
+            }
+            if ((maximumWindowHeight > 0)
+                && (getHeight() > maximumWindowHeight)
+            ) {
+                setHeight(maximumWindowHeight);
+            }
+            if (getHeight() + getY() >= getApplication().getDesktopBottom()) {
+                setHeight(getApplication().getDesktopBottom() - getY());
+            }
+
+            // Pass a resize event to my children
+            onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                    getWidth(), getHeight()));
+            return;
+        }
+
+        // Give the shortcut bar a shot at this.
+        if (statusBar != null) {
+            statusBar.statusBarMouseMotion(mouse);
+        }
+
+        // I didn't take it, pass it on to my children
+        super.onMouseMotion(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+
+        if (inWindowMove || inWindowResize) {
+            // ESC or ENTER - Exit size/move
+            if (keypress.equals(kbEsc) || keypress.equals(kbEnter)) {
+                inWindowMove = false;
+                inWindowResize = false;
+                return;
+            }
+        }
+
+        if (inKeyboardResize) {
+
+            // ESC or ENTER - Exit size/move
+            if (keypress.equals(kbEsc) || keypress.equals(kbEnter)) {
+                inKeyboardResize = false;
+            }
+
+            if (keypress.equals(kbLeft)) {
+                if (getX() > 0) {
+                    setX(getX() - 1);
+                }
+            }
+            if (keypress.equals(kbRight)) {
+                if (getX() < getScreen().getWidth() - 1) {
+                    setX(getX() + 1);
+                }
+            }
+            if (keypress.equals(kbDown)) {
+                if (getY() < application.getDesktopBottom() - 1) {
+                    setY(getY() + 1);
+                }
+            }
+            if (keypress.equals(kbUp)) {
+                if (getY() > 1) {
+                    setY(getY() - 1);
+                }
+            }
+
+            /*
+             * Only permit keyboard resizing if the window was RESIZABLE.
+             */
+            if ((flags & RESIZABLE) != 0) {
+
+                if (keypress.equals(kbShiftLeft)) {
+                    if ((getWidth() > minimumWindowWidth)
+                        || (minimumWindowWidth <= 0)
+                    ) {
+                        setWidth(getWidth() - 1);
+                    }
+                }
+                if (keypress.equals(kbShiftRight)) {
+                    if ((getWidth() < maximumWindowWidth)
+                        || (maximumWindowWidth <= 0)
+                    ) {
+                        setWidth(getWidth() + 1);
+                    }
+                }
+                if (keypress.equals(kbShiftUp)) {
+                    if ((getHeight() > minimumWindowHeight)
+                        || (minimumWindowHeight <= 0)
+                    ) {
+                        setHeight(getHeight() - 1);
+                    }
+                }
+                if (keypress.equals(kbShiftDown)) {
+                    if ((getHeight() < maximumWindowHeight)
+                        || (maximumWindowHeight <= 0)
+                    ) {
+                        setHeight(getHeight() + 1);
+                    }
+                }
+
+                // Pass a resize event to my children
+                onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                        getWidth(), getHeight()));
+
+            } // if ((flags & RESIZABLE) != 0)
+
+            return;
+        }
+
+        // Give the shortcut bar a shot at this.
+        if (statusBar != null) {
+            if (statusBar.statusBarKeypress(keypress)) {
+                return;
+            }
+        }
+
+        // These keystrokes will typically not be seen unless a subclass
+        // overrides onMenu() due to how TApplication dispatches
+        // accelerators.
+
+        if (!(this instanceof TDesktop)) {
+
+            // Ctrl-W - close window
+            if (keypress.equals(kbCtrlW)) {
+                if ((flags & NOCLOSEBOX) == 0) {
+                    if ((flags & HIDEONCLOSE) == 0) {
+                        // Close window
+                        application.closeWindow(this);
+                    } else {
+                        // Hide window
+                        application.hideWindow(this);
+                    }
+                }
+                return;
+            }
+
+            // F6 - behave like Alt-TAB
+            if (keypress.equals(kbF6)) {
+                application.switchWindow(true);
+                return;
+            }
+
+            // Shift-F6 - behave like Shift-Alt-TAB
+            if (keypress.equals(kbShiftF6)) {
+                application.switchWindow(false);
+                return;
+            }
+
+            // F5 - zoom
+            if (keypress.equals(kbF5) && ((flags & NOZOOMBOX) == 0)) {
+                if (maximized) {
+                    restore();
+                } else {
+                    maximize();
+                }
+            }
+
+            // Ctrl-F5 - size/move
+            if (keypress.equals(kbCtrlF5)) {
+                inKeyboardResize = !inKeyboardResize;
+            }
+
+        } // if (!(this instanceof TDesktop))
+
+        // I didn't take it, pass it on to my children
+        super.onKeypress(keypress);
+    }
+
+    /**
+     * Handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+
+        // These commands will typically not be seen unless a subclass
+        // overrides onMenu() due to how TApplication dispatches
+        // accelerators.
+
+        if (!(this instanceof TDesktop)) {
+
+            if (command.equals(cmWindowClose)) {
+                if ((flags & NOCLOSEBOX) == 0) {
+                    if ((flags & HIDEONCLOSE) == 0) {
+                        // Close window
+                        application.closeWindow(this);
+                    } else {
+                        // Hide window
+                        application.hideWindow(this);
+                    }
+                }
+                return;
+            }
+
+            if (command.equals(cmWindowNext)) {
+                application.switchWindow(true);
+                return;
+            }
+
+            if (command.equals(cmWindowPrevious)) {
+                application.switchWindow(false);
+                return;
+            }
+
+            if (command.equals(cmWindowMove)) {
+                inKeyboardResize = true;
+                return;
+            }
+
+            if (command.equals(cmWindowZoom) && ((flags & NOZOOMBOX) == 0)) {
+                if (maximized) {
+                    restore();
+                } else {
+                    maximize();
+                }
+            }
+
+        } // if (!(this instanceof TDesktop))
+
+        // I didn't take it, pass it on to my children
+        super.onCommand(command);
+    }
+
+    /**
+     * Handle posted menu events.
+     *
+     * @param menu menu event
+     */
+    @Override
+    public void onMenu(final TMenuEvent menu) {
+
+        if (!(this instanceof TDesktop)) {
+
+            if (menu.getId() == TMenu.MID_WINDOW_CLOSE) {
+                if ((flags & NOCLOSEBOX) == 0) {
+                    if ((flags & HIDEONCLOSE) == 0) {
+                        // Close window
+                        application.closeWindow(this);
+                    } else {
+                        // Hide window
+                        application.hideWindow(this);
+                    }
+                }
+                return;
+            }
+
+            if (menu.getId() == TMenu.MID_WINDOW_NEXT) {
+                application.switchWindow(true);
+                return;
+            }
+
+            if (menu.getId() == TMenu.MID_WINDOW_PREVIOUS) {
+                application.switchWindow(false);
+                return;
+            }
+
+            if (menu.getId() == TMenu.MID_WINDOW_MOVE) {
+                inKeyboardResize = true;
+                return;
+            }
+
+            if ((menu.getId() == TMenu.MID_WINDOW_ZOOM)
+                && ((flags & NOZOOMBOX) == 0)
+            ) {
+                if (maximized) {
+                    restore();
+                } else {
+                    maximize();
+                }
+                return;
+            }
+
+        } // if (!(this instanceof TDesktop))
+
+        // I didn't take it, pass it on to my children
+        super.onMenu(menu);
+    }
+
+    /**
+     * Method that subclasses can override to handle window/screen resize
+     * events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            if (getChildren().size() == 1) {
+                TWidget child = getChildren().get(0);
+                if ((child instanceof TSplitPane)
+                    || (child instanceof TPanel)
+                ) {
+                    if (this instanceof TDesktop) {
+                        child.onResize(new TResizeEvent(
+                            TResizeEvent.Type.WIDGET,
+                            resize.getWidth(), resize.getHeight()));
+                    } else {
+                        child.onResize(new TResizeEvent(
+                            TResizeEvent.Type.WIDGET,
+                            resize.getWidth() - 2, resize.getHeight() - 2));
+                    }
+                }
+                return;
+            }
+        }
+
+        // Pass on to TWidget.
+        super.onResize(resize);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get this TWindow's parent TApplication.
+     *
+     * @return this TWindow's parent TApplication
+     */
+    @Override
+    public final TApplication getApplication() {
+        return application;
+    }
+
+    /**
+     * Get the Screen.
+     *
+     * @return the Screen
+     */
+    @Override
+    public final Screen getScreen() {
+        return application.getScreen();
+    }
+
+    /**
+     * Called by TApplication.drawChildren() to render on screen.
+     */
+    @Override
+    public void draw() {
+        // Draw the box and background first.
+        CellAttributes border = getBorder();
+        CellAttributes background = getBackground();
+        int borderType = getBorderType();
+
+        drawBox(0, 0, getWidth(), getHeight(), border, background, borderType,
+            true);
+
+        // Draw the title
+        int titleLength = StringUtils.width(title);
+        int titleLeft = (getWidth() - titleLength - 2) / 2;
+        putCharXY(titleLeft, 0, ' ', border);
+        putStringXY(titleLeft + 1, 0, title, border);
+        putCharXY(titleLeft + titleLength + 1, 0, ' ', border);
+
+        if (isActive()) {
+
+            // Draw the close button
+            if ((flags & NOCLOSEBOX) == 0) {
+                putCharXY(2, 0, '[', border);
+                putCharXY(4, 0, ']', border);
+                if (mouseOnClose() && mouse.isMouse1()) {
+                    putCharXY(3, 0, GraphicsChars.CP437[0x0F],
+                        getBorderControls());
+                } else {
+                    putCharXY(3, 0, GraphicsChars.CP437[0xFE],
+                        getBorderControls());
+                }
+            }
+
+            // Draw the maximize button
+            if (!isModal() && ((flags & NOZOOMBOX) == 0)) {
+
+                putCharXY(getWidth() - 5, 0, '[', border);
+                putCharXY(getWidth() - 3, 0, ']', border);
+                if (mouseOnMaximize() && mouse.isMouse1()) {
+                    putCharXY(getWidth() - 4, 0, GraphicsChars.CP437[0x0F],
+                        getBorderControls());
+                } else {
+                    if (maximized) {
+                        putCharXY(getWidth() - 4, 0, GraphicsChars.CP437[0x12],
+                            getBorderControls());
+                    } else {
+                        putCharXY(getWidth() - 4, 0, GraphicsChars.UPARROW,
+                            getBorderControls());
+                    }
+                }
+
+                // Draw the resize corner
+                if ((flags & RESIZABLE) != 0) {
+                    putCharXY(getWidth() - 2, getHeight() - 1,
+                        GraphicsChars.SINGLE_BAR, getBorderControls());
+                    putCharXY(getWidth() - 1, getHeight() - 1,
+                        GraphicsChars.LRCORNER, getBorderControls());
+                }
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get window title.
+     *
+     * @return window title
+     */
+    public final String getTitle() {
+        return title;
+    }
+
+    /**
+     * Set window title.
+     *
+     * @param title new window title
+     */
+    public final void setTitle(final String title) {
+        this.title = title;
+    }
+
+    /**
+     * Get Z order.  Lower number means more in-front.
+     *
+     * @return Z value.  Lower number means more in-front.
+     */
+    public final int getZ() {
+        return z;
+    }
+
+    /**
+     * Set Z order.  Lower number means more in-front.
+     *
+     * @param z the new Z value.  Lower number means more in-front.
+     */
+    public final void setZ(final int z) {
+        this.z = z;
+    }
+
+    /**
+     * Add a keypress to be overridden for this window.
+     *
+     * @param key the key to start taking control of
+     */
+    protected void addShortcutKeypress(final TKeypress key) {
+        keyboardShortcuts.add(key);
+    }
+
+    /**
+     * Remove a keypress to be overridden for this window.
+     *
+     * @param key the key to stop taking control of
+     */
+    protected void removeShortcutKeypress(final TKeypress key) {
+        keyboardShortcuts.remove(key);
+    }
+
+    /**
+     * Remove all keypresses to be overridden for this window.
+     */
+    protected void clearShortcutKeypresses() {
+        keyboardShortcuts.clear();
+    }
+
+    /**
+     * Determine if a keypress is overridden for this window.
+     *
+     * @param key the key to check
+     * @return true if this window wants to process this key on its own
+     */
+    public boolean isShortcutKeypress(final TKeypress key) {
+        return keyboardShortcuts.contains(key);
+    }
+
+    /**
+     * Get the window's status bar, or null if it does not have one.
+     *
+     * @return the status bar, or null
+     */
+    public TStatusBar getStatusBar() {
+        return statusBar;
+    }
+
+    /**
+     * Set the window's status bar to a new one.
+     *
+     * @param text the status bar text
+     * @return the status bar
+     */
+    public TStatusBar newStatusBar(final String text) {
+        statusBar = new TStatusBar(this, text);
+        return statusBar;
+    }
+
+    /**
+     * Set the maximum width for this window.
+     *
+     * @param maximumWindowWidth new maximum width
+     */
+    public final void setMaximumWindowWidth(final int maximumWindowWidth) {
+        if ((maximumWindowWidth != -1)
+            && (maximumWindowWidth < minimumWindowWidth + 1)
+        ) {
+            throw new IllegalArgumentException("Maximum window width cannot " +
+                "be smaller than minimum window width + 1");
+        }
+        this.maximumWindowWidth = maximumWindowWidth;
+    }
+
+    /**
+     * Set the minimum width for this window.
+     *
+     * @param minimumWindowWidth new minimum width
+     */
+    public final void setMinimumWindowWidth(final int minimumWindowWidth) {
+        if ((maximumWindowWidth != -1)
+            && (minimumWindowWidth > maximumWindowWidth - 1)
+        ) {
+            throw new IllegalArgumentException("Minimum window width cannot " +
+                "be larger than maximum window width - 1");
+        }
+        this.minimumWindowWidth = minimumWindowWidth;
+    }
+
+    /**
+     * Set the maximum height for this window.
+     *
+     * @param maximumWindowHeight new maximum height
+     */
+    public final void setMaximumWindowHeight(final int maximumWindowHeight) {
+        if ((maximumWindowHeight != -1)
+            && (maximumWindowHeight < minimumWindowHeight + 1)
+        ) {
+            throw new IllegalArgumentException("Maximum window height cannot " +
+                "be smaller than minimum window height + 1");
+        }
+        this.maximumWindowHeight = maximumWindowHeight;
+    }
+
+    /**
+     * Set the minimum height for this window.
+     *
+     * @param minimumWindowHeight new minimum height
+     */
+    public final void setMinimumWindowHeight(final int minimumWindowHeight) {
+        if ((maximumWindowHeight != -1)
+            && (minimumWindowHeight > maximumWindowHeight - 1)
+        ) {
+            throw new IllegalArgumentException("Minimum window height cannot " +
+                "be larger than maximum window height - 1");
+        }
+        this.minimumWindowHeight = minimumWindowHeight;
+    }
+
+    /**
+     * Recenter the window on-screen.
+     */
+    public final void center() {
+        if ((flags & CENTERED) != 0) {
+            if (getWidth() < getScreen().getWidth()) {
+                setX((getScreen().getWidth() - getWidth()) / 2);
+            } else {
+                setX(0);
+            }
+            setY(((application.getDesktopBottom()
+                    - application.getDesktopTop()) - getHeight()) / 2);
+            if (getY() < 0) {
+                setY(0);
+            }
+            setY(getY() + application.getDesktopTop());
+        }
+    }
+
+    /**
+     * Maximize window.
+     */
+    public void maximize() {
+        if (maximized) {
+            return;
+        }
+
+        restoreWindowWidth = getWidth();
+        restoreWindowHeight = getHeight();
+        restoreWindowX = getX();
+        restoreWindowY = getY();
+        setWidth(getScreen().getWidth());
+        setHeight(application.getDesktopBottom() - application.getDesktopTop());
+        setX(0);
+        setY(application.getDesktopTop());
+        maximized = true;
+
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+    }
+
+    /**
+     * Restore (unmaximize) window.
+     */
+    public void restore() {
+        if (!maximized) {
+            return;
+        }
+
+        setWidth(restoreWindowWidth);
+        setHeight(restoreWindowHeight);
+        setX(restoreWindowX);
+        setY(restoreWindowY);
+        maximized = false;
+
+        onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                getHeight()));
+    }
+
+    /**
+     * Returns true if this window is hidden.
+     *
+     * @return true if this window is hidden, false if the window is shown
+     */
+    public final boolean isHidden() {
+        return hidden;
+    }
+
+    /**
+     * Returns true if this window is shown.
+     *
+     * @return true if this window is shown, false if the window is hidden
+     */
+    public final boolean isShown() {
+        return !hidden;
+    }
+
+    /**
+     * Hide window.  A hidden window will still have its onIdle() called, and
+     * will also have onClose() called at application exit.  Hidden windows
+     * will not receive any other events.
+     */
+    public void hide() {
+        application.hideWindow(this);
+    }
+
+    /**
+     * Show window.
+     */
+    public void show() {
+        application.showWindow(this);
+    }
+
+    /**
+     * Activate window (bring to top and receive events).
+     */
+    @Override
+    public void activate() {
+        application.activateWindow(this);
+    }
+
+    /**
+     * Close window.  Note that windows without a close box can still be
+     * closed by calling the close() method.
+     */
+    @Override
+    public void close() {
+        application.closeWindow(this);
+    }
+
+    /**
+     * See if this window is undergoing any movement/resize/etc.
+     *
+     * @return true if the window is moving
+     */
+    public boolean inMovements() {
+        if (inWindowResize || inWindowMove || inKeyboardResize) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Stop any pending movement/resize/etc.
+     */
+    public void stopMovements() {
+        inWindowResize = false;
+        inWindowMove = false;
+        inKeyboardResize = false;
+    }
+
+    /**
+     * Returns true if this window is modal.
+     *
+     * @return true if this window is modal
+     */
+    public final boolean isModal() {
+        if ((flags & MODAL) == 0) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Returns true if this window has a close box.
+     *
+     * @return true if this window has a close box
+     */
+    public final boolean hasCloseBox() {
+        if ((flags & NOCLOSEBOX) != 0) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if this window has a maximize/zoom box.
+     *
+     * @return true if this window has a maximize/zoom box
+     */
+    public final boolean hasZoomBox() {
+        if ((flags & NOZOOMBOX) != 0) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if this window does not want menus to work while it is
+     * visible.
+     *
+     * @return true if this window does not want menus to work while it is
+     * visible
+     */
+    public final boolean hasOverriddenMenu() {
+        if ((flags & OVERRIDEMENU) != 0) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Retrieve the background color.
+     *
+     * @return the background color
+     */
+    public CellAttributes getBackground() {
+        if (!isModal()
+            && (inWindowMove || inWindowResize || inKeyboardResize)
+        ) {
+            assert (isActive());
+            return getTheme().getColor("twindow.background.windowmove");
+        } else if (isModal() && inWindowMove) {
+            assert (isActive());
+            return getTheme().getColor("twindow.background.modal");
+        } else if (isModal()) {
+            if (isActive()) {
+                return getTheme().getColor("twindow.background.modal");
+            }
+            return getTheme().getColor("twindow.background.modal.inactive");
+        } else if (isActive()) {
+            assert (!isModal());
+            return getTheme().getColor("twindow.background");
+        } else {
+            assert (!isModal());
+            return getTheme().getColor("twindow.background.inactive");
+        }
+    }
+
+    /**
+     * Retrieve the border color.
+     *
+     * @return the border color
+     */
+    public CellAttributes getBorder() {
+        if (!isModal()
+            && (inWindowMove || inWindowResize || inKeyboardResize)
+        ) {
+            if (!isActive()) {
+                // The user's terminal never passed a mouse up event, and now
+                // another window is active but we never finished a drag.
+                inWindowMove = false;
+                inWindowResize = false;
+                inKeyboardResize = false;
+                return getTheme().getColor("twindow.border.inactive");
+            }
+
+            return getTheme().getColor("twindow.border.windowmove");
+        } else if (isModal() && inWindowMove) {
+            assert (isActive());
+            return getTheme().getColor("twindow.border.modal.windowmove");
+        } else if (isModal()) {
+            if (isActive()) {
+                return getTheme().getColor("twindow.border.modal");
+            } else {
+                return getTheme().getColor("twindow.border.modal.inactive");
+            }
+        } else if (isActive()) {
+            assert (!isModal());
+            return getTheme().getColor("twindow.border");
+        } else {
+            assert (!isModal());
+            return getTheme().getColor("twindow.border.inactive");
+        }
+    }
+
+    /**
+     * Retrieve the color used by the window movement/sizing controls.
+     *
+     * @return the color used by the zoom box, resize bar, and close box
+     */
+    public CellAttributes getBorderControls() {
+        if (isModal()) {
+            return getTheme().getColor("twindow.border.modal.windowmove");
+        }
+        return getTheme().getColor("twindow.border.windowmove");
+    }
+
+    /**
+     * Retrieve the border line type.
+     *
+     * @return the border line type
+     */
+    private int getBorderType() {
+        if (!isModal()
+            && (inWindowMove || inWindowResize || inKeyboardResize)
+        ) {
+            assert (isActive());
+            return 1;
+        } else if (isModal() && inWindowMove) {
+            assert (isActive());
+            return 1;
+        } else if (isModal()) {
+            if (isActive()) {
+                return 2;
+            } else {
+                return 1;
+            }
+        } else if (isActive()) {
+            return 2;
+        } else {
+            return 1;
+        }
+    }
+
+    /**
+     * Returns true if this window does not want the application-wide mouse
+     * cursor drawn over it.
+     *
+     * @return true if this window does not want the application-wide mouse
+     * cursor drawn over it
+     */
+    public boolean hasHiddenMouse() {
+        return hideMouse;
+    }
+
+    /**
+     * Set request to prevent the application-wide mouse cursor from being
+     * drawn over this window.
+     *
+     * @param hideMouse if true, this window does not want the
+     * application-wide mouse cursor drawn over it
+     */
+    public final void setHiddenMouse(final boolean hideMouse) {
+        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.
+     *
+     * @return a human-readable string
+     */
+    @Override
+    public String toString() {
+        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/backend/Backend.java b/src/jexer/backend/Backend.java
new file mode 100644 (file)
index 0000000..eaed7e6
--- /dev/null
@@ -0,0 +1,104 @@
+/*
+ * 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.backend;
+
+import java.util.List;
+
+import jexer.event.TInputEvent;
+
+/**
+ * This interface provides a screen, keyboard, and mouse to TApplication.  It
+ * also exposes session information as gleaned from lower levels of the
+ * communication stack.
+ */
+public interface Backend {
+
+    /**
+     * Get a SessionInfo, which exposes text width/height, language,
+     * username, and other information from the communication stack.
+     *
+     * @return the SessionInfo
+     */
+    public SessionInfo getSessionInfo();
+
+    /**
+     * Get a Screen, which displays the text cells to the user.
+     *
+     * @return the Screen
+     */
+    public Screen getScreen();
+
+    /**
+     * Classes must provide an implementation that syncs the logical screen
+     * to the physical device.
+     */
+    public void flushScreen();
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the application
+     */
+    public boolean hasEvents();
+
+    /**
+     * Classes must provide an implementation to get keyboard, mouse, and
+     * screen resize events.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(List<TInputEvent> queue);
+
+    /**
+     * Classes must provide an implementation that closes sockets, restores
+     * console, etc.
+     */
+    public void shutdown();
+
+    /**
+     * Classes must provide an implementation that sets the window title.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title);
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener);
+
+    /**
+     * Reload backend options from System properties.
+     */
+    public void reloadOptions();
+
+}
diff --git a/src/jexer/backend/ECMA48Backend.java b/src/jexer/backend/ECMA48Backend.java
new file mode 100644 (file)
index 0000000..0614e17
--- /dev/null
@@ -0,0 +1,168 @@
+/*
+ * 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.backend;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+
+/**
+ * This class uses an xterm/ANSI X3.64/ECMA-48 type terminal to provide a
+ * screen, keyboard, and mouse to TApplication.
+ */
+public class ECMA48Backend extends GenericBackend {
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor will use System.in and System.out and UTF-8
+     * encoding. On non-Windows systems System.in will be put in raw mode;
+     * shutdown() will (blindly!) put System.in in cooked mode.
+     *
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48Backend() throws UnsupportedEncodingException {
+        this(null, null, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; shutdown() will (blindly!) put System.in in cooked
+     * mode.  input is always converted to a Reader with UTF-8 encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points.  ECMA48 cannot set it, but it is
+     * here to match the Swing API.
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48Backend(final Object listener, final InputStream input,
+        final OutputStream output, final int windowWidth,
+        final int windowHeight, final int fontSize)
+        throws UnsupportedEncodingException {
+
+        // Create a terminal and explicitly set stdin into raw mode
+        terminal = new ECMA48Terminal(listener, input, output, windowWidth,
+            windowHeight);
+
+        // Keep the terminal's sessionInfo so that TApplication can see it
+        sessionInfo = ((ECMA48Terminal) terminal).getSessionInfo();
+
+        // ECMA48Terminal is the screen too
+        screen = (ECMA48Terminal) terminal;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; shutdown() will (blindly!) put System.in in cooked
+     * mode.  input is always converted to a Reader with UTF-8 encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48Backend(final Object listener, final InputStream input,
+        final OutputStream output) throws UnsupportedEncodingException {
+
+        // Create a terminal and explicitly set stdin into raw mode
+        terminal = new ECMA48Terminal(listener, input, output);
+
+        // Keep the terminal's sessionInfo so that TApplication can see it
+        sessionInfo = ((ECMA48Terminal) terminal).getSessionInfo();
+
+        // ECMA48Terminal is the screen too
+        screen = (ECMA48Terminal) terminal;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @param setRawMode if true, set System.in into raw mode with stty.
+     * This should in general not be used.  It is here solely for Demo3,
+     * which uses System.in.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public ECMA48Backend(final Object listener, final InputStream input,
+        final Reader reader, final PrintWriter writer,
+        final boolean setRawMode) {
+
+        // Create a terminal and explicitly set stdin into raw mode
+        terminal = new ECMA48Terminal(listener, input, reader, writer,
+            setRawMode);
+
+        // Keep the terminal's sessionInfo so that TApplication can see it
+        sessionInfo = ((ECMA48Terminal) terminal).getSessionInfo();
+
+        // ECMA48Terminal is the screen too
+        screen = (ECMA48Terminal) terminal;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public ECMA48Backend(final Object listener, final InputStream input,
+        final Reader reader, final PrintWriter writer) {
+
+        this(listener, input, reader, writer, false);
+    }
+
+}
diff --git a/src/jexer/backend/ECMA48Terminal.java b/src/jexer/backend/ECMA48Terminal.java
new file mode 100644 (file)
index 0000000..429e698
--- /dev/null
@@ -0,0 +1,4370 @@
+/*
+ * 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.backend;
+
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import javax.imageio.ImageIO;
+
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.Color;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
+import jexer.event.TInputEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This class reads keystrokes and mouse events and emits output to ANSI
+ * X3.64 / ECMA-48 type terminals e.g. xterm, linux, vt100, ansi.sys, etc.
+ */
+public class ECMA48Terminal extends LogicalScreen
+                            implements TerminalReader, Runnable {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * States in the input parser.
+     */
+    private enum ParseState {
+        GROUND,
+        ESCAPE,
+        ESCAPE_INTERMEDIATE,
+        CSI_ENTRY,
+        CSI_PARAM,
+        MOUSE,
+        MOUSE_SGR,
+    }
+
+    /**
+     * Available Jexer images support.
+     */
+    private enum JexerImageOption {
+        DISABLED,
+        JPG,
+        PNG,
+        RGB,
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Emit debugging to stderr.
+     */
+    private boolean debugToStderr = false;
+
+    /**
+     * If true, emit T.416-style RGB colors for normal system colors.  This
+     * is a) expensive in bandwidth, and b) potentially terrible looking for
+     * non-xterms.
+     */
+    private static boolean doRgbColor = false;
+
+    /**
+     * The session information.
+     */
+    private SessionInfo sessionInfo;
+
+    /**
+     * The event queue, filled up by a thread reading on input.
+     */
+    private List<TInputEvent> eventQueue;
+
+    /**
+     * If true, we want the reader thread to exit gracefully.
+     */
+    private boolean stopReaderThread;
+
+    /**
+     * The reader thread.
+     */
+    private Thread readerThread;
+
+    /**
+     * Parameters being collected.  E.g. if the string is \033[1;3m, then
+     * params[0] will be 1 and params[1] will be 3.
+     */
+    private List<String> params;
+
+    /**
+     * Current parsing state.
+     */
+    private ParseState state;
+
+    /**
+     * The time we entered ESCAPE.  If we get a bare escape without a code
+     * following it, this is used to return that bare escape.
+     */
+    private long escapeTime;
+
+    /**
+     * The time we last checked the window size.  We try not to spawn stty
+     * more than once per second.
+     */
+    private long windowSizeTime;
+
+    /**
+     * true if mouse1 was down.  Used to report mouse1 on the release event.
+     */
+    private boolean mouse1;
+
+    /**
+     * true if mouse2 was down.  Used to report mouse2 on the release event.
+     */
+    private boolean mouse2;
+
+    /**
+     * true if mouse3 was down.  Used to report mouse3 on the release event.
+     */
+    private boolean mouse3;
+
+    /**
+     * Cache the cursor visibility value so we only emit the sequence when we
+     * need to.
+     */
+    private boolean cursorOn = true;
+
+    /**
+     * Cache the last window size to figure out if a TResizeEvent needs to be
+     * generated.
+     */
+    private TResizeEvent windowResize = null;
+
+    /**
+     * If true, emit wide-char (CJK/Emoji) characters as sixel images.
+     */
+    private boolean wideCharImages = true;
+
+    /**
+     * Window width in pixels.  Used for sixel support.
+     */
+    private int widthPixels = 640;
+
+    /**
+     * Window height in pixels.  Used for sixel support.
+     */
+    private int heightPixels = 400;
+
+    /**
+     * If true, emit image data via sixel.
+     */
+    private boolean sixel = true;
+
+    /**
+     * If true, use a single shared palette for sixel.
+     */
+    private boolean sixelSharedPalette = true;
+
+    /**
+     * The sixel palette handler.
+     */
+    private SixelPalette palette = null;
+
+    /**
+     * The sixel post-rendered string cache.
+     */
+    private ImageCache sixelCache = null;
+
+    /**
+     * Number of colors in the sixel palette.  Xterm 335 defines the max as
+     * 1024.  Valid values are: 2 (black and white), 256, 512, 1024, and
+     * 2048.
+     */
+    private int sixelPaletteSize = 1024;
+
+    /**
+     * If true, emit image data via iTerm2 image protocol.
+     */
+    private boolean iterm2Images = false;
+
+    /**
+     * The iTerm2 post-rendered string cache.
+     */
+    private ImageCache iterm2Cache = null;
+
+    /**
+     * If not DISABLED, emit image data via Jexer image protocol if the
+     * terminal supports it.
+     */
+    private JexerImageOption jexerImageOption = JexerImageOption.JPG;
+
+    /**
+     * The Jexer post-rendered string cache.
+     */
+    private ImageCache jexerCache = null;
+
+    /**
+     * If true, then we changed System.in and need to change it back.
+     */
+    private boolean setRawMode = false;
+
+    /**
+     * If true, '?' was seen in terminal response.
+     */
+    private boolean decPrivateModeFlag = false;
+
+    /**
+     * The terminal's input.  If an InputStream is not specified in the
+     * constructor, then this InputStreamReader will be bound to System.in
+     * with UTF-8 encoding.
+     */
+    private Reader input;
+
+    /**
+     * The terminal's raw InputStream.  If an InputStream is not specified in
+     * the constructor, then this InputReader will be bound to System.in.
+     * This is used by run() to see if bytes are available() before calling
+     * (Reader)input.read().
+     */
+    private InputStream inputStream;
+
+    /**
+     * The terminal's output.  If an OutputStream is not specified in the
+     * constructor, then this PrintWriter will be bound to System.out with
+     * UTF-8 encoding.
+     */
+    private PrintWriter output;
+
+    /**
+     * The listening object that run() wakes up on new input.
+     */
+    private Object listener;
+
+    // Colors to map DOS colors to AWT colors.
+    private static java.awt.Color MYBLACK;
+    private static java.awt.Color MYRED;
+    private static java.awt.Color MYGREEN;
+    private static java.awt.Color MYYELLOW;
+    private static java.awt.Color MYBLUE;
+    private static java.awt.Color MYMAGENTA;
+    private static java.awt.Color MYCYAN;
+    private static java.awt.Color MYWHITE;
+    private static java.awt.Color MYBOLD_BLACK;
+    private static java.awt.Color MYBOLD_RED;
+    private static java.awt.Color MYBOLD_GREEN;
+    private static java.awt.Color MYBOLD_YELLOW;
+    private static java.awt.Color MYBOLD_BLUE;
+    private static java.awt.Color MYBOLD_MAGENTA;
+    private static java.awt.Color MYBOLD_CYAN;
+    private static java.awt.Color MYBOLD_WHITE;
+
+    /**
+     * SixelPalette is used to manage the conversion of images between 24-bit
+     * RGB color and a palette of sixelPaletteSize colors.
+     */
+    private class SixelPalette {
+
+        /**
+         * Color palette for sixel output, sorted low to high.
+         */
+        private List<Integer> rgbColors = new ArrayList<Integer>();
+
+        /**
+         * Map of color palette index for sixel output, from the order it was
+         * generated by makePalette() to rgbColors.
+         */
+        private int [] rgbSortedIndex = new int[sixelPaletteSize];
+
+        /**
+         * The color palette, organized by hue, saturation, and luminance.
+         * This is used for a fast color match.
+         */
+        private ArrayList<ArrayList<ArrayList<ColorIdx>>> hslColors;
+
+        /**
+         * Number of bits for hue.
+         */
+        private int hueBits = -1;
+
+        /**
+         * Number of bits for saturation.
+         */
+        private int satBits = -1;
+
+        /**
+         * Number of bits for luminance.
+         */
+        private int lumBits = -1;
+
+        /**
+         * Step size for hue bins.
+         */
+        private int hueStep = -1;
+
+        /**
+         * Step size for saturation bins.
+         */
+        private int satStep = -1;
+
+        /**
+         * Cached RGB to HSL result.
+         */
+        private int hsl[] = new int[3];
+
+        /**
+         * ColorIdx records a RGB color and its palette index.
+         */
+        private class ColorIdx {
+            /**
+             * The 24-bit RGB color.
+             */
+            public int color;
+
+            /**
+             * The palette index for this color.
+             */
+            public int index;
+
+            /**
+             * Public constructor.
+             *
+             * @param color the 24-bit RGB color
+             * @param index the palette index for this color
+             */
+            public ColorIdx(final int color, final int index) {
+                this.color = color;
+                this.index = index;
+            }
+        }
+
+        /**
+         * Public constructor.
+         */
+        public SixelPalette() {
+            makePalette();
+        }
+
+        /**
+         * Find the nearest match for a color in the palette.
+         *
+         * @param color the RGB color
+         * @return the index in rgbColors that is closest to color
+         */
+        public int matchColor(final int color) {
+
+            assert (color >= 0);
+
+            /*
+             * matchColor() is a critical performance bottleneck.  To make it
+             * decent, we do the following:
+             *
+             *   1. Find the nearest two hues that bracket this color.
+             *
+             *   2. Find the nearest two saturations that bracket this color.
+             *
+             *   3. Iterate within these four bands of luminance values,
+             *      returning the closest color by Euclidean distance.
+             *
+             * This strategy reduces the search space by about 97%.
+             */
+            int red   = (color >>> 16) & 0xFF;
+            int green = (color >>>  8) & 0xFF;
+            int blue  =  color         & 0xFF;
+
+            if (sixelPaletteSize == 2) {
+                if (((red * red) + (green * green) + (blue * blue)) < 35568) {
+                    // Black
+                    return 0;
+                }
+                // White
+                return 1;
+            }
+
+
+            rgbToHsl(red, green, blue, hsl);
+            int hue = hsl[0];
+            int sat = hsl[1];
+            int lum = hsl[2];
+            // System.err.printf("%d %d %d\n", hue, sat, lum);
+
+            double diff = Double.MAX_VALUE;
+            int idx = -1;
+
+            int hue1 = hue / (360/hueStep);
+            int hue2 = hue1 + 1;
+            if (hue1 >= hslColors.size() - 1) {
+                // Bracket pure red from above.
+                hue1 = hslColors.size() - 1;
+                hue2 = 0;
+            } else if (hue1 == 0) {
+                // Bracket pure red from below.
+                hue2 = hslColors.size() - 1;
+            }
+
+            for (int hI = hue1; hI != -1;) {
+                ArrayList<ArrayList<ColorIdx>> sats = hslColors.get(hI);
+                if (hI == hue1) {
+                    hI = hue2;
+                } else if (hI == hue2) {
+                    hI = -1;
+                }
+
+                int sMin = (sat / satStep) - 1;
+                int sMax = sMin + 1;
+                if (sMin < 0) {
+                    sMin = 0;
+                    sMax = 1;
+                } else if (sMin == sats.size() - 1) {
+                    sMax = sMin;
+                    sMin--;
+                }
+                assert (sMin >= 0);
+                assert (sMax - sMin == 1);
+
+                // int sMin = 0;
+                // int sMax = sats.size() - 1;
+
+                for (int sI = sMin; sI <= sMax; sI++) {
+                    ArrayList<ColorIdx> lums = sats.get(sI);
+
+                    // True 3D colorspace match for the remaining values
+                    for (ColorIdx c: lums) {
+                        int rgbColor = c.color;
+                        double newDiff = 0;
+                        int red2   = (rgbColor >>> 16) & 0xFF;
+                        int green2 = (rgbColor >>>  8) & 0xFF;
+                        int blue2  =  rgbColor         & 0xFF;
+                        newDiff += Math.pow(red2 - red, 2);
+                        newDiff += Math.pow(green2 - green, 2);
+                        newDiff += Math.pow(blue2 - blue, 2);
+                        if (newDiff < diff) {
+                            idx = rgbSortedIndex[c.index];
+                            diff = newDiff;
+                        }
+                    }
+                }
+            }
+
+            if (((red * red) + (green * green) + (blue * blue)) < diff) {
+                // Black is a closer match.
+                idx = 0;
+            } else if ((((255 - red) * (255 - red)) +
+                    ((255 - green) * (255 - green)) +
+                    ((255 - blue) * (255 - blue))) < diff) {
+
+                // White is a closer match.
+                idx = sixelPaletteSize - 1;
+            }
+            assert (idx != -1);
+            return idx;
+        }
+
+        /**
+         * Clamp an int value to [0, 255].
+         *
+         * @param x the int value
+         * @return an int between 0 and 255.
+         */
+        private int clamp(final int x) {
+            if (x < 0) {
+                return 0;
+            }
+            if (x > 255) {
+                return 255;
+            }
+            return x;
+        }
+
+        /**
+         * Dither an image to a sixelPaletteSize palette.  The dithered
+         * image cells will contain indexes into the palette.
+         *
+         * @param image the image to dither
+         * @return the dithered image.  Every pixel is an index into the
+         * palette.
+         */
+        public BufferedImage ditherImage(final BufferedImage image) {
+
+            BufferedImage ditheredImage = new BufferedImage(image.getWidth(),
+                image.getHeight(), BufferedImage.TYPE_INT_ARGB);
+
+            int [] rgbArray = image.getRGB(0, 0, image.getWidth(),
+                image.getHeight(), null, 0, image.getWidth());
+            ditheredImage.setRGB(0, 0, image.getWidth(), image.getHeight(),
+                rgbArray, 0, image.getWidth());
+
+            for (int imageY = 0; imageY < image.getHeight(); imageY++) {
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                    int oldPixel = ditheredImage.getRGB(imageX,
+                        imageY) & 0xFFFFFF;
+                    int colorIdx = matchColor(oldPixel);
+                    assert (colorIdx >= 0);
+                    assert (colorIdx < sixelPaletteSize);
+                    int newPixel = rgbColors.get(colorIdx);
+                    ditheredImage.setRGB(imageX, imageY, colorIdx);
+
+                    int oldRed   = (oldPixel >>> 16) & 0xFF;
+                    int oldGreen = (oldPixel >>>  8) & 0xFF;
+                    int oldBlue  =  oldPixel         & 0xFF;
+
+                    int newRed   = (newPixel >>> 16) & 0xFF;
+                    int newGreen = (newPixel >>>  8) & 0xFF;
+                    int newBlue  =  newPixel         & 0xFF;
+
+                    int redError   = (oldRed - newRed) / 16;
+                    int greenError = (oldGreen - newGreen) / 16;
+                    int blueError  = (oldBlue - newBlue) / 16;
+
+                    int red, green, blue;
+                    if (imageX < image.getWidth() - 1) {
+                        int pXpY  = ditheredImage.getRGB(imageX + 1, imageY);
+                        red   = ((pXpY >>> 16) & 0xFF) + (7 * redError);
+                        green = ((pXpY >>>  8) & 0xFF) + (7 * greenError);
+                        blue  = ( pXpY         & 0xFF) + (7 * blueError);
+                        red = clamp(red);
+                        green = clamp(green);
+                        blue = clamp(blue);
+                        pXpY = ((red & 0xFF) << 16);
+                        pXpY |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                        ditheredImage.setRGB(imageX + 1, imageY, pXpY);
+
+                        if (imageY < image.getHeight() - 1) {
+                            int pXpYp = ditheredImage.getRGB(imageX + 1,
+                                imageY + 1);
+                            red   = ((pXpYp >>> 16) & 0xFF) + redError;
+                            green = ((pXpYp >>>  8) & 0xFF) + greenError;
+                            blue  = ( pXpYp         & 0xFF) + blueError;
+                            red = clamp(red);
+                            green = clamp(green);
+                            blue = clamp(blue);
+                            pXpYp = ((red & 0xFF) << 16);
+                            pXpYp |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                            ditheredImage.setRGB(imageX + 1, imageY + 1, pXpYp);
+                        }
+                    } else if (imageY < image.getHeight() - 1) {
+                        int pXmYp = ditheredImage.getRGB(imageX - 1,
+                            imageY + 1);
+                        int pXYp  = ditheredImage.getRGB(imageX,
+                            imageY + 1);
+
+                        red   = ((pXmYp >>> 16) & 0xFF) + (3 * redError);
+                        green = ((pXmYp >>>  8) & 0xFF) + (3 * greenError);
+                        blue  = ( pXmYp         & 0xFF) + (3 * blueError);
+                        red = clamp(red);
+                        green = clamp(green);
+                        blue = clamp(blue);
+                        pXmYp = ((red & 0xFF) << 16);
+                        pXmYp |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                        ditheredImage.setRGB(imageX - 1, imageY + 1, pXmYp);
+
+                        red   = ((pXYp >>> 16) & 0xFF) + (5 * redError);
+                        green = ((pXYp >>>  8) & 0xFF) + (5 * greenError);
+                        blue  = ( pXYp         & 0xFF) + (5 * blueError);
+                        red = clamp(red);
+                        green = clamp(green);
+                        blue = clamp(blue);
+                        pXYp = ((red & 0xFF) << 16);
+                        pXYp |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                        ditheredImage.setRGB(imageX,     imageY + 1, pXYp);
+                    }
+                } // for (int imageY = 0; imageY < image.getHeight(); imageY++)
+            } // for (int imageX = 0; imageX < image.getWidth(); imageX++)
+
+            return ditheredImage;
+        }
+
+        /**
+         * Convert an RGB color to HSL.
+         *
+         * @param red red color, between 0 and 255
+         * @param green green color, between 0 and 255
+         * @param blue blue color, between 0 and 255
+         * @param hsl the hsl color as [hue, saturation, luminance]
+         */
+        private void rgbToHsl(final int red, final int green,
+            final int blue, final int [] hsl) {
+
+            assert ((red >= 0) && (red <= 255));
+            assert ((green >= 0) && (green <= 255));
+            assert ((blue >= 0) && (blue <= 255));
+
+            double R = red / 255.0;
+            double G = green / 255.0;
+            double B = blue / 255.0;
+            boolean Rmax = false;
+            boolean Gmax = false;
+            boolean Bmax = false;
+            double min = (R < G ? R : G);
+            min = (min < B ? min : B);
+            double max = 0;
+            if ((R >= G) && (R >= B)) {
+                max = R;
+                Rmax = true;
+            } else if ((G >= R) && (G >= B)) {
+                max = G;
+                Gmax = true;
+            } else if ((B >= G) && (B >= R)) {
+                max = B;
+                Bmax = true;
+            }
+
+            double L = (min + max) / 2.0;
+            double H = 0.0;
+            double S = 0.0;
+            if (min != max) {
+                if (L < 0.5) {
+                    S = (max - min) / (max + min);
+                } else {
+                    S = (max - min) / (2.0 - max - min);
+                }
+            }
+            if (Rmax) {
+                assert (Gmax == false);
+                assert (Bmax == false);
+                H = (G - B) / (max - min);
+            } else if (Gmax) {
+                assert (Rmax == false);
+                assert (Bmax == false);
+                H = 2.0 + (B - R) / (max - min);
+            } else if (Bmax) {
+                assert (Rmax == false);
+                assert (Gmax == false);
+                H = 4.0 + (R - G) / (max - min);
+            }
+            if (H < 0.0) {
+                H += 6.0;
+            }
+            hsl[0] = (int) (H * 60.0);
+            hsl[1] = (int) (S * 100.0);
+            hsl[2] = (int) (L * 100.0);
+
+            assert ((hsl[0] >= 0) && (hsl[0] <= 360));
+            assert ((hsl[1] >= 0) && (hsl[1] <= 100));
+            assert ((hsl[2] >= 0) && (hsl[2] <= 100));
+        }
+
+        /**
+         * Convert a HSL color to RGB.
+         *
+         * @param hue hue, between 0 and 359
+         * @param sat saturation, between 0 and 100
+         * @param lum luminance, between 0 and 100
+         * @return the rgb color as 0x00RRGGBB
+         */
+        private int hslToRgb(final int hue, final int sat, final int lum) {
+            assert ((hue >= 0) && (hue <= 360));
+            assert ((sat >= 0) && (sat <= 100));
+            assert ((lum >= 0) && (lum <= 100));
+
+            double S = sat / 100.0;
+            double L = lum / 100.0;
+            double C = (1.0 - Math.abs((2.0 * L) - 1.0)) * S;
+            double Hp = hue / 60.0;
+            double X = C * (1.0 - Math.abs((Hp % 2) - 1.0));
+            double Rp = 0.0;
+            double Gp = 0.0;
+            double Bp = 0.0;
+            if (Hp <= 1.0) {
+                Rp = C;
+                Gp = X;
+            } else if (Hp <= 2.0) {
+                Rp = X;
+                Gp = C;
+            } else if (Hp <= 3.0) {
+                Gp = C;
+                Bp = X;
+            } else if (Hp <= 4.0) {
+                Gp = X;
+                Bp = C;
+            } else if (Hp <= 5.0) {
+                Rp = X;
+                Bp = C;
+            } else if (Hp <= 6.0) {
+                Rp = C;
+                Bp = X;
+            }
+            double m = L - (C / 2.0);
+            int red   = ((int) ((Rp + m) * 255.0)) << 16;
+            int green = ((int) ((Gp + m) * 255.0)) << 8;
+            int blue  =  (int) ((Bp + m) * 255.0);
+
+            return (red | green | blue);
+        }
+
+        /**
+         * Create the sixel palette.
+         */
+        private void makePalette() {
+            // Generate the sixel palette.  Because we have no idea at this
+            // layer which image(s) will be shown, we have to use a common
+            // palette with sixelPaletteSize colors for everything, and
+            // map the BufferedImage colors to their nearest neighbor in RGB
+            // space.
+
+            if (sixelPaletteSize == 2) {
+                rgbColors.add(0);
+                rgbColors.add(0xFFFFFF);
+                rgbSortedIndex[0] = 0;
+                rgbSortedIndex[1] = 1;
+                return;
+            }
+
+            // We build a palette using the Hue-Saturation-Luminence model,
+            // with 5+ bits for Hue, 2+ bits for Saturation, and 1+ bit for
+            // Luminance.  We convert these colors to 24-bit RGB, sort them
+            // ascending, and steal the first index for pure black and the
+            // last for pure white.  The 8-bit final palette favors bright
+            // colors, somewhere between pastel and classic television
+            // technicolor.  9- and 10-bit palettes are more uniform.
+
+            // Default at 256 colors.
+            hueBits = 5;
+            satBits = 2;
+            lumBits = 1;
+
+            assert (sixelPaletteSize >= 256);
+            assert ((sixelPaletteSize == 256)
+                || (sixelPaletteSize == 512)
+                || (sixelPaletteSize == 1024)
+                || (sixelPaletteSize == 2048));
+
+            switch (sixelPaletteSize) {
+            case 512:
+                hueBits = 5;
+                satBits = 2;
+                lumBits = 2;
+                break;
+            case 1024:
+                hueBits = 5;
+                satBits = 2;
+                lumBits = 3;
+                break;
+            case 2048:
+                hueBits = 5;
+                satBits = 3;
+                lumBits = 3;
+                break;
+            }
+            hueStep = (int) (Math.pow(2, hueBits));
+            satStep = (int) (100 / Math.pow(2, satBits));
+            // 1 bit for luminance: 40 and 70.
+            int lumBegin = 40;
+            int lumStep = 30;
+            switch (lumBits) {
+            case 2:
+                // 2 bits: 20, 40, 60, 80
+                lumBegin = 20;
+                lumStep = 20;
+                break;
+            case 3:
+                // 3 bits: 8, 20, 32, 44, 56, 68, 80, 92
+                lumBegin = 8;
+                lumStep = 12;
+                break;
+            }
+
+            // System.err.printf("<html><body>\n");
+            // Hue is evenly spaced around the wheel.
+            hslColors = new ArrayList<ArrayList<ArrayList<ColorIdx>>>();
+
+            final boolean DEBUG = false;
+            ArrayList<Integer> rawRgbList = new ArrayList<Integer>();
+
+            for (int hue = 0; hue < (360 - (360 % hueStep));
+                 hue += (360/hueStep)) {
+
+                ArrayList<ArrayList<ColorIdx>> satList = null;
+                satList = new ArrayList<ArrayList<ColorIdx>>();
+                hslColors.add(satList);
+
+                // Saturation is linearly spaced between pastel and pure.
+                for (int sat = satStep; sat <= 100; sat += satStep) {
+
+                    ArrayList<ColorIdx> lumList = new ArrayList<ColorIdx>();
+                    satList.add(lumList);
+
+                    // Luminance brackets the pure color, but leaning toward
+                    // lighter.
+                    for (int lum = lumBegin; lum < 100; lum += lumStep) {
+                        /*
+                        System.err.printf("<font style = \"color:");
+                        System.err.printf("hsl(%d, %d%%, %d%%)",
+                            hue, sat, lum);
+                        System.err.printf(";\">=</font>\n");
+                        */
+                        int rgbColor = hslToRgb(hue, sat, lum);
+                        rgbColors.add(rgbColor);
+                        ColorIdx colorIdx = new ColorIdx(rgbColor,
+                            rgbColors.size() - 1);
+                        lumList.add(colorIdx);
+
+                        rawRgbList.add(rgbColor);
+                        if (DEBUG) {
+                            int red   = (rgbColor >>> 16) & 0xFF;
+                            int green = (rgbColor >>>  8) & 0xFF;
+                            int blue  =  rgbColor         & 0xFF;
+                            int [] backToHsl = new int[3];
+                            rgbToHsl(red, green, blue, backToHsl);
+                            System.err.printf("%d [%d] %d [%d] %d [%d]\n",
+                                hue, backToHsl[0], sat, backToHsl[1],
+                                lum, backToHsl[2]);
+                        }
+                    }
+                }
+            }
+            // System.err.printf("\n</body></html>\n");
+
+            assert (rgbColors.size() == sixelPaletteSize);
+
+            /*
+             * We need to sort rgbColors, so that toSixel() can know where
+             * BLACK and WHITE are in it.  But we also need to be able to
+             * find the sorted values using the old unsorted indexes.  So we
+             * will sort it, put all the indexes into a HashMap, and then
+             * build rgbSortedIndex[].
+             */
+            Collections.sort(rgbColors);
+            HashMap<Integer, Integer> rgbColorIndices = null;
+            rgbColorIndices = new HashMap<Integer, Integer>();
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                rgbColorIndices.put(rgbColors.get(i), i);
+            }
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                int rawColor = rawRgbList.get(i);
+                rgbSortedIndex[i] = rgbColorIndices.get(rawColor);
+            }
+            if (DEBUG) {
+                for (int i = 0; i < sixelPaletteSize; i++) {
+                    assert (rawRgbList != null);
+                    int idx = rgbSortedIndex[i];
+                    int rgbColor = rgbColors.get(idx);
+                    if ((idx != 0) && (idx != sixelPaletteSize - 1)) {
+                        /*
+                        System.err.printf("%d %06x --> %d %06x\n",
+                            i, rawRgbList.get(i), idx, rgbColors.get(idx));
+                        */
+                        assert (rgbColor == rawRgbList.get(i));
+                    }
+                }
+            }
+
+            // Set the dimmest color as true black, and the brightest as true
+            // white.
+            rgbColors.set(0, 0);
+            rgbColors.set(sixelPaletteSize - 1, 0xFFFFFF);
+
+            /*
+            System.err.printf("<html><body>\n");
+            for (Integer rgb: rgbColors) {
+                System.err.printf("<font style = \"color:");
+                System.err.printf("#%06x", rgb);
+                System.err.printf(";\">=</font>\n");
+            }
+            System.err.printf("\n</body></html>\n");
+            */
+
+        }
+
+        /**
+         * Emit the sixel palette.
+         *
+         * @param sb the StringBuilder to append to
+         * @param used array of booleans set to true for each color actually
+         * used in this cell, or null to emit the entire palette
+         * @return the string to emit to an ANSI / ECMA-style terminal
+         */
+        public String emitPalette(final StringBuilder sb,
+            final boolean [] used) {
+
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                if (((used != null) && (used[i] == true)) || (used == null)) {
+                    int rgbColor = rgbColors.get(i);
+                    sb.append(String.format("#%d;2;%d;%d;%d", i,
+                            ((rgbColor >>> 16) & 0xFF) * 100 / 255,
+                            ((rgbColor >>>  8) & 0xFF) * 100 / 255,
+                            ( rgbColor         & 0xFF) * 100 / 255));
+                }
+            }
+            return sb.toString();
+        }
+    }
+
+    /**
+     * ImageCache is a least-recently-used cache that hangs on to the
+     * post-rendered sixel or iTerm2 string for a particular set of cells.
+     */
+    private class ImageCache {
+
+        /**
+         * Maximum size of the cache.
+         */
+        private int maxSize = 100;
+
+        /**
+         * The entries stored in the cache.
+         */
+        private HashMap<String, CacheEntry> cache = null;
+
+        /**
+         * CacheEntry is one entry in the cache.
+         */
+        private class CacheEntry {
+            /**
+             * The cache key.
+             */
+            public String key;
+
+            /**
+             * The cache data.
+             */
+            public String data;
+
+            /**
+             * The last time this entry was used.
+             */
+            public long millis = 0;
+
+            /**
+             * Public constructor.
+             *
+             * @param key the cache entry key
+             * @param data the cache entry data
+             */
+            public CacheEntry(final String key, final String data) {
+                this.key = key;
+                this.data = data;
+                this.millis = System.currentTimeMillis();
+            }
+        }
+
+        /**
+         * Public constructor.
+         *
+         * @param maxSize the maximum size of the cache
+         */
+        public ImageCache(final int maxSize) {
+            this.maxSize = maxSize;
+            cache = new HashMap<String, CacheEntry>();
+        }
+
+        /**
+         * Make a unique key for a list of cells.
+         *
+         * @param cells the cells
+         * @return the key
+         */
+        private String makeKey(final ArrayList<Cell> cells) {
+            StringBuilder sb = new StringBuilder();
+            for (Cell cell: cells) {
+                sb.append(cell.hashCode());
+            }
+            return sb.toString();
+        }
+
+        /**
+         * Get an entry from the cache.
+         *
+         * @param cells the list of cells that are the cache key
+         * @return the sixel string representing these cells, or null if this
+         * list of cells is not in the cache
+         */
+        public String get(final ArrayList<Cell> cells) {
+            CacheEntry entry = cache.get(makeKey(cells));
+            if (entry == null) {
+                return null;
+            }
+            entry.millis = System.currentTimeMillis();
+            return entry.data;
+        }
+
+        /**
+         * Put an entry into the cache.
+         *
+         * @param cells the list of cells that are the cache key
+         * @param data the sixel string representing these cells
+         */
+        public void put(final ArrayList<Cell> cells, final String data) {
+            String key = makeKey(cells);
+
+            // System.err.println("put() " + key + " size " + cache.size());
+
+            assert (!cache.containsKey(key));
+
+            assert (cache.size() <= maxSize);
+            if (cache.size() == maxSize) {
+                // Cache is at limit, evict oldest entry.
+                long oldestTime = Long.MAX_VALUE;
+                String keyToRemove = null;
+                for (CacheEntry entry: cache.values()) {
+                    if ((entry.millis < oldestTime) || (keyToRemove == null)) {
+                        keyToRemove = entry.key;
+                        oldestTime = entry.millis;
+                    }
+                }
+                /*
+                System.err.println("put() remove key = " + keyToRemove +
+                    " size " + cache.size());
+                 */
+                assert (keyToRemove != null);
+                cache.remove(keyToRemove);
+                /*
+                System.err.println("put() removed, size " + cache.size());
+                 */
+            }
+            assert (cache.size() <= maxSize);
+            CacheEntry entry = new CacheEntry(key, data);
+            assert (key.equals(entry.key));
+            cache.put(key, entry);
+            /*
+            System.err.println("put() added key " + key + " " +
+                " size " + cache.size());
+             */
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Constructor sets up state for getEvent().  If either windowWidth or
+     * windowHeight are less than 1, the terminal is not resized.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; closeTerminal() will (blindly!) put System.in in
+     * cooked mode.  input is always converted to a Reader with UTF-8
+     * encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48Terminal(final Object listener, final InputStream input,
+        final OutputStream output, final int windowWidth,
+        final int windowHeight) throws UnsupportedEncodingException {
+
+        this(listener, input, output);
+
+        // Send dtterm/xterm sequences, which will probably not work because
+        // allowWindowOps is defaulted to false.
+        if ((windowWidth > 0) && (windowHeight > 0)) {
+            String resizeString = String.format("\033[8;%d;%dt", windowHeight,
+                windowWidth);
+            this.output.write(resizeString);
+            this.output.flush();
+        }
+    }
+
+    /**
+     * Constructor sets up state for getEvent().
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; closeTerminal() will (blindly!) put System.in in
+     * cooked mode.  input is always converted to a Reader with UTF-8
+     * encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48Terminal(final Object listener, final InputStream input,
+        final OutputStream output) throws UnsupportedEncodingException {
+
+        resetParser();
+        mouse1           = false;
+        mouse2           = false;
+        mouse3           = false;
+        stopReaderThread = false;
+        this.listener    = listener;
+
+        if (input == null) {
+            // inputStream = System.in;
+            inputStream = new FileInputStream(FileDescriptor.in);
+            sttyRaw();
+            setRawMode = true;
+        } else {
+            inputStream = input;
+        }
+        this.input = new InputStreamReader(inputStream, "UTF-8");
+
+        if (input instanceof SessionInfo) {
+            // This is a TelnetInputStream that exposes window size and
+            // environment variables from the telnet layer.
+            sessionInfo = (SessionInfo) input;
+        }
+        if (sessionInfo == null) {
+            if (input == null) {
+                // Reading right off the tty
+                sessionInfo = new TTYSessionInfo();
+            } else {
+                sessionInfo = new TSessionInfo();
+            }
+        }
+
+        if (output == null) {
+            this.output = new PrintWriter(new OutputStreamWriter(System.out,
+                    "UTF-8"));
+        } else {
+            this.output = new PrintWriter(new OutputStreamWriter(output,
+                    "UTF-8"));
+        }
+
+        // Request Device Attributes
+        this.output.printf("\033[c");
+
+        // Request xterm report window/cell dimensions in pixels
+        this.output.printf("%s", xtermReportPixelDimensions());
+
+        // Enable mouse reporting and metaSendsEscape
+        this.output.printf("%s%s", mouse(true), xtermMetaSendsEscape(true));
+
+        // Request xterm use the sixel settings we want
+        this.output.printf("%s", xtermSetSixelSettings());
+
+        this.output.flush();
+
+        // Query the screen size
+        sessionInfo.queryWindowSize();
+        setDimensions(sessionInfo.getWindowWidth(),
+            sessionInfo.getWindowHeight());
+
+        // Hang onto the window size
+        windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+            sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
+
+        reloadOptions();
+
+        // Spin up the input reader
+        eventQueue = new ArrayList<TInputEvent>();
+        readerThread = new Thread(this);
+        readerThread.start();
+
+        // Clear the screen
+        this.output.write(clearAll());
+        this.output.flush();
+    }
+
+    /**
+     * Constructor sets up state for getEvent().
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @param setRawMode if true, set System.in into raw mode with stty.
+     * This should in general not be used.  It is here solely for Demo3,
+     * which uses System.in.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public ECMA48Terminal(final Object listener, final InputStream input,
+        final Reader reader, final PrintWriter writer,
+        final boolean setRawMode) {
+
+        if (input == null) {
+            throw new IllegalArgumentException("InputStream must be specified");
+        }
+        if (reader == null) {
+            throw new IllegalArgumentException("Reader must be specified");
+        }
+        if (writer == null) {
+            throw new IllegalArgumentException("Writer must be specified");
+        }
+        resetParser();
+        mouse1           = false;
+        mouse2           = false;
+        mouse3           = false;
+        stopReaderThread = false;
+        this.listener    = listener;
+
+        inputStream = input;
+        this.input = reader;
+
+        if (setRawMode == true) {
+            sttyRaw();
+        }
+        this.setRawMode = setRawMode;
+
+        if (input instanceof SessionInfo) {
+            // This is a TelnetInputStream that exposes window size and
+            // environment variables from the telnet layer.
+            sessionInfo = (SessionInfo) input;
+        }
+        if (sessionInfo == null) {
+            if (setRawMode == true) {
+                // Reading right off the tty
+                sessionInfo = new TTYSessionInfo();
+            } else {
+                sessionInfo = new TSessionInfo();
+            }
+        }
+
+        this.output = writer;
+
+        // Request Device Attributes
+        this.output.printf("\033[c");
+
+        // Request xterm report window/cell dimensions in pixels
+        this.output.printf("%s", xtermReportPixelDimensions());
+
+        // Enable mouse reporting and metaSendsEscape
+        this.output.printf("%s%s", mouse(true), xtermMetaSendsEscape(true));
+
+        // Request xterm use the sixel settings we want
+        this.output.printf("%s", xtermSetSixelSettings());
+
+        this.output.flush();
+
+        // Query the screen size
+        sessionInfo.queryWindowSize();
+        setDimensions(sessionInfo.getWindowWidth(),
+            sessionInfo.getWindowHeight());
+
+        // Hang onto the window size
+        windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+            sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
+
+        reloadOptions();
+
+        // Spin up the input reader
+        eventQueue = new ArrayList<TInputEvent>();
+        readerThread = new Thread(this);
+        readerThread.start();
+
+        // Clear the screen
+        this.output.write(clearAll());
+        this.output.flush();
+    }
+
+    /**
+     * Constructor sets up state for getEvent().
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public ECMA48Terminal(final Object listener, final InputStream input,
+        final Reader reader, final PrintWriter writer) {
+
+        this(listener, input, reader, writer, false);
+    }
+
+    // ------------------------------------------------------------------------
+    // LogicalScreen ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    @Override
+    public void setTitle(final String title) {
+        output.write(getSetTitleString(title));
+        flush();
+    }
+
+    /**
+     * Push the logical screen to the physical device.
+     */
+    @Override
+    public void flushPhysical() {
+        StringBuilder sb = new StringBuilder();
+        if ((cursorVisible)
+            && (cursorY >= 0)
+            && (cursorX >= 0)
+            && (cursorY <= height - 1)
+            && (cursorX <= width - 1)
+        ) {
+            flushString(sb);
+            sb.append(cursor(true));
+            sb.append(gotoXY(cursorX, cursorY));
+        } else {
+            sb.append(cursor(false));
+            flushString(sb);
+        }
+        output.write(sb.toString());
+        flush();
+    }
+
+    /**
+     * Resize the physical screen to match the logical screen dimensions.
+     */
+    @Override
+    public void resizeToScreen() {
+        // Send dtterm/xterm sequences, which will probably not work because
+        // allowWindowOps is defaulted to false.
+        String resizeString = String.format("\033[8;%d;%dt", getHeight(),
+            getWidth());
+        this.output.write(resizeString);
+        this.output.flush();
+    }
+
+    // ------------------------------------------------------------------------
+    // TerminalReader ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the backend
+     */
+    public boolean hasEvents() {
+        synchronized (eventQueue) {
+            return (eventQueue.size() > 0);
+        }
+    }
+
+    /**
+     * Return any events in the IO queue.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(final List<TInputEvent> queue) {
+        synchronized (eventQueue) {
+            if (eventQueue.size() > 0) {
+                synchronized (queue) {
+                    queue.addAll(eventQueue);
+                }
+                eventQueue.clear();
+            }
+        }
+    }
+
+    /**
+     * Restore terminal to normal state.
+     */
+    public void closeTerminal() {
+
+        // System.err.println("=== closeTerminal() ==="); System.err.flush();
+
+        // Tell the reader thread to stop looking at input
+        stopReaderThread = true;
+        try {
+            readerThread.join();
+        } catch (InterruptedException e) {
+            if (debugToStderr) {
+                e.printStackTrace();
+            }
+        }
+
+        // Disable mouse reporting and show cursor.  Defensive null check
+        // here in case closeTerminal() is called twice.
+        if (output != null) {
+            output.printf("%s%s%s%s", mouse(false), cursor(true),
+                defaultColor(), xtermResetSixelSettings());
+            output.flush();
+        }
+
+        if (setRawMode) {
+            sttyCooked();
+            setRawMode = false;
+            // We don't close System.in/out
+        } else {
+            // Shut down the streams, this should wake up the reader thread
+            // and make it exit.
+            if (input != null) {
+                try {
+                    input.close();
+                } catch (IOException e) {
+                    // SQUASH
+                }
+                input = null;
+            }
+            if (output != null) {
+                output.close();
+                output = null;
+            }
+        }
+    }
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener) {
+        this.listener = listener;
+    }
+
+    /**
+     * Reload options from System properties.
+     */
+    public void reloadOptions() {
+        // Permit RGB colors only if externally requested.
+        if (System.getProperty("jexer.ECMA48.rgbColor",
+                "false").equals("true")
+        ) {
+            doRgbColor = true;
+        } else {
+            doRgbColor = false;
+        }
+
+        // Default to using images for full-width characters.
+        if (System.getProperty("jexer.ECMA48.wideCharImages",
+                "true").equals("true")) {
+            wideCharImages = true;
+        } else {
+            wideCharImages = false;
+        }
+
+        // Pull the system properties for sixel output.
+        if (System.getProperty("jexer.ECMA48.sixel", "true").equals("true")) {
+            sixel = true;
+        } else {
+            sixel = false;
+        }
+
+        // Palette size
+        int paletteSize = 1024;
+        try {
+            paletteSize = Integer.parseInt(System.getProperty(
+                "jexer.ECMA48.sixelPaletteSize", "1024"));
+            switch (paletteSize) {
+            case 2:
+            case 256:
+            case 512:
+            case 1024:
+            case 2048:
+                sixelPaletteSize = paletteSize;
+                break;
+            default:
+                // Ignore value
+                break;
+            }
+        } catch (NumberFormatException e) {
+            // SQUASH
+        }
+
+        // Shared palette
+        if (System.getProperty("jexer.ECMA48.sixelSharedPalette",
+                "true").equals("false")) {
+            sixelSharedPalette = false;
+        } else {
+            sixelSharedPalette = true;
+        }
+
+        // Default to not supporting iTerm2 images.
+        if (System.getProperty("jexer.ECMA48.iTerm2Images",
+                "false").equals("true")) {
+            iterm2Images = true;
+        } else {
+            iterm2Images = false;
+        }
+
+        // Default to using JPG Jexer images if terminal supports it.
+        String jexerImageStr = System.getProperty("jexer.ECMA48.jexerImages",
+            "jpg").toLowerCase();
+        if (jexerImageStr.equals("false")) {
+            jexerImageOption = JexerImageOption.DISABLED;
+        } else if (jexerImageStr.equals("jpg")) {
+            jexerImageOption = JexerImageOption.JPG;
+        } else if (jexerImageStr.equals("png")) {
+            jexerImageOption = JexerImageOption.PNG;
+        } else if (jexerImageStr.equals("rgb")) {
+            jexerImageOption = JexerImageOption.RGB;
+        }
+
+        // Set custom colors
+        setCustomSystemColors();
+    }
+
+    // ------------------------------------------------------------------------
+    // Runnable ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Read function runs on a separate thread.
+     */
+    public void run() {
+        boolean done = false;
+        // available() will often return > 1, so we need to read in chunks to
+        // stay caught up.
+        char [] readBuffer = new char[128];
+        List<TInputEvent> events = new ArrayList<TInputEvent>();
+
+        while (!done && !stopReaderThread) {
+            try {
+                // We assume that if inputStream has bytes available, then
+                // input won't block on read().
+                int n = inputStream.available();
+
+                /*
+                System.err.printf("inputStream.available(): %d\n", n);
+                System.err.flush();
+                */
+
+                if (n > 0) {
+                    if (readBuffer.length < n) {
+                        // The buffer wasn't big enough, make it huger
+                        readBuffer = new char[readBuffer.length * 2];
+                    }
+
+                    // System.err.printf("BEFORE read()\n"); System.err.flush();
+
+                    int rc = input.read(readBuffer, 0, readBuffer.length);
+
+                    /*
+                    System.err.printf("AFTER read() %d\n", rc);
+                    System.err.flush();
+                    */
+
+                    if (rc == -1) {
+                        // This is EOF
+                        done = true;
+                    } else {
+                        for (int i = 0; i < rc; i++) {
+                            int ch = readBuffer[i];
+                            processChar(events, (char)ch);
+                        }
+                        getIdleEvents(events);
+                        if (events.size() > 0) {
+                            // Add to the queue for the backend thread to
+                            // be able to obtain.
+                            synchronized (eventQueue) {
+                                eventQueue.addAll(events);
+                            }
+                            if (listener != null) {
+                                synchronized (listener) {
+                                    listener.notifyAll();
+                                }
+                            }
+                            events.clear();
+                        }
+                    }
+                } else {
+                    getIdleEvents(events);
+                    if (events.size() > 0) {
+                        synchronized (eventQueue) {
+                            eventQueue.addAll(events);
+                        }
+                        if (listener != null) {
+                            synchronized (listener) {
+                                listener.notifyAll();
+                            }
+                        }
+                        events.clear();
+                    }
+
+                    if (output.checkError()) {
+                        // This is EOF.
+                        done = true;
+                    }
+
+                    // Wait 20 millis for more data
+                    Thread.sleep(20);
+                }
+                // System.err.println("end while loop"); System.err.flush();
+            } catch (InterruptedException e) {
+                // SQUASH
+            } catch (IOException e) {
+                e.printStackTrace();
+                done = true;
+            }
+        } // while ((done == false) && (stopReaderThread == false))
+
+        // Pass an event up to TApplication to tell it this Backend is done.
+        synchronized (eventQueue) {
+            eventQueue.add(new TCommandEvent(cmBackendDisconnect));
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+
+        // System.err.println("*** run() exiting..."); System.err.flush();
+    }
+
+    // ------------------------------------------------------------------------
+    // ECMA48Terminal ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the width of a character cell in pixels.
+     *
+     * @return the width in pixels of a character cell
+     */
+    public int getTextWidth() {
+        if (sessionInfo.getWindowWidth() > 0) {
+            return (widthPixels / sessionInfo.getWindowWidth());
+        }
+        return 16;
+    }
+
+    /**
+     * Get the height of a character cell in pixels.
+     *
+     * @return the height in pixels of a character cell
+     */
+    public int getTextHeight() {
+        if (sessionInfo.getWindowHeight() > 0) {
+            return (heightPixels / sessionInfo.getWindowHeight());
+        }
+        return 20;
+    }
+
+    /**
+     * Getter for sessionInfo.
+     *
+     * @return the SessionInfo
+     */
+    public SessionInfo getSessionInfo() {
+        return sessionInfo;
+    }
+
+    /**
+     * Get the output writer.
+     *
+     * @return the Writer
+     */
+    public PrintWriter getOutput() {
+        return output;
+    }
+
+    /**
+     * Call 'stty' to set cooked mode.
+     *
+     * <p>Actually executes '/bin/sh -c stty sane cooked &lt; /dev/tty'
+     */
+    private void sttyCooked() {
+        doStty(false);
+    }
+
+    /**
+     * Call 'stty' to set raw mode.
+     *
+     * <p>Actually executes '/bin/sh -c stty -ignbrk -brkint -parmrk -istrip
+     * -inlcr -igncr -icrnl -ixon -opost -echo -echonl -icanon -isig -iexten
+     * -parenb cs8 min 1 &lt; /dev/tty'
+     */
+    private void sttyRaw() {
+        doStty(true);
+    }
+
+    /**
+     * Call 'stty' to set raw or cooked mode.
+     *
+     * @param mode if true, set raw mode, otherwise set cooked mode
+     */
+    private void doStty(final boolean mode) {
+        String [] cmdRaw = {
+            "/bin/sh", "-c", "stty -ignbrk -brkint -parmrk -istrip -inlcr -igncr -icrnl -ixon -opost -echo -echonl -icanon -isig -iexten -parenb cs8 min 1 < /dev/tty"
+        };
+        String [] cmdCooked = {
+            "/bin/sh", "-c", "stty sane cooked < /dev/tty"
+        };
+        try {
+            Process process;
+            if (mode) {
+                process = Runtime.getRuntime().exec(cmdRaw);
+            } else {
+                process = Runtime.getRuntime().exec(cmdCooked);
+            }
+            BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"));
+            String line = in.readLine();
+            if ((line != null) && (line.length() > 0)) {
+                System.err.println("WEIRD?! Normal output from stty: " + line);
+            }
+            while (true) {
+                BufferedReader err = new BufferedReader(new InputStreamReader(process.getErrorStream(), "UTF-8"));
+                line = err.readLine();
+                if ((line != null) && (line.length() > 0)) {
+                    System.err.println("Error output from stty: " + line);
+                }
+                try {
+                    process.waitFor();
+                    break;
+                } catch (InterruptedException e) {
+                    if (debugToStderr) {
+                        e.printStackTrace();
+                    }
+                }
+            }
+            int rc = process.exitValue();
+            if (rc != 0) {
+                System.err.println("stty returned error code: " + rc);
+            }
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * Flush output.
+     */
+    public void flush() {
+        output.flush();
+    }
+
+    /**
+     * Perform a somewhat-optimal rendering of a line.
+     *
+     * @param y row coordinate.  0 is the top-most row.
+     * @param sb StringBuilder to write escape sequences to
+     * @param lastAttr cell attributes from the last call to flushLine
+     */
+    private void flushLine(final int y, final StringBuilder sb,
+        CellAttributes lastAttr) {
+
+        int lastX = -1;
+        int textEnd = 0;
+        for (int x = 0; x < width; x++) {
+            Cell lCell = logical[x][y];
+            if (!lCell.isBlank()) {
+                textEnd = x;
+            }
+        }
+        // Push textEnd to first column beyond the text area
+        textEnd++;
+
+        // DEBUG
+        // reallyCleared = true;
+
+        boolean hasImage = false;
+
+        for (int x = 0; x < width; x++) {
+            Cell lCell = logical[x][y];
+            Cell pCell = physical[x][y];
+
+            if (!lCell.equals(pCell) || reallyCleared) {
+
+                if (debugToStderr) {
+                    System.err.printf("\n--\n");
+                    System.err.printf(" Y: %d X: %d\n", y, x);
+                    System.err.printf("   lCell: %s\n", lCell);
+                    System.err.printf("   pCell: %s\n", pCell);
+                    System.err.printf("    ====    \n");
+                }
+
+                if (lastAttr == null) {
+                    lastAttr = new CellAttributes();
+                    sb.append(normal());
+                }
+
+                // Place the cell
+                if ((lastX != (x - 1)) || (lastX == -1)) {
+                    // Advancing at least one cell, or the first gotoXY
+                    sb.append(gotoXY(x, y));
+                }
+
+                assert (lastAttr != null);
+
+                if ((x == textEnd) && (textEnd < width - 1)) {
+                    assert (lCell.isBlank());
+
+                    for (int i = x; i < width; i++) {
+                        assert (logical[i][y].isBlank());
+                        // Physical is always updated
+                        physical[i][y].reset();
+                    }
+
+                    // Clear remaining line
+                    sb.append(clearRemainingLine());
+                    lastAttr.reset();
+                    return;
+                }
+
+                // Image cell: bypass the rest of the loop, it is not
+                // rendered here.
+                if ((wideCharImages && lCell.isImage())
+                    || (!wideCharImages
+                        && lCell.isImage()
+                        && (lCell.getWidth() == Cell.Width.SINGLE))
+                ) {
+                    hasImage = true;
+
+                    // Save the last rendered cell
+                    lastX = x;
+
+                    // Physical is always updated
+                    physical[x][y].setTo(lCell);
+                    continue;
+                }
+
+                assert ((wideCharImages && !lCell.isImage())
+                    || (!wideCharImages
+                        && (!lCell.isImage()
+                            || (lCell.isImage()
+                                && (lCell.getWidth() != Cell.Width.SINGLE)))));
+
+                if (!wideCharImages && (lCell.getWidth() == Cell.Width.RIGHT)) {
+                    continue;
+                }
+
+                if (hasImage) {
+                    hasImage = false;
+                    sb.append(gotoXY(x, y));
+                }
+
+                // Now emit only the modified attributes
+                if ((lCell.getForeColor() != lastAttr.getForeColor())
+                    && (lCell.getBackColor() != lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Both colors changed, attributes the same
+                    sb.append(color(lCell.isBold(),
+                            lCell.getForeColor(), lCell.getBackColor()));
+
+                    if (debugToStderr) {
+                        System.err.printf("1 Change only fore/back colors\n");
+                    }
+
+                } else if (lCell.isRGB()
+                    && (lCell.getForeColorRGB() != lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() != lastAttr.getBackColorRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Both colors changed, attributes the same
+                    sb.append(colorRGB(lCell.getForeColorRGB(),
+                            lCell.getBackColorRGB()));
+
+                    if (debugToStderr) {
+                        System.err.printf("1 Change only fore/back colors (RGB)\n");
+                    }
+                } else if ((lCell.getForeColor() != lastAttr.getForeColor())
+                    && (lCell.getBackColor() != lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() != lastAttr.isBold())
+                    && (lCell.isReverse() != lastAttr.isReverse())
+                    && (lCell.isUnderline() != lastAttr.isUnderline())
+                    && (lCell.isBlink() != lastAttr.isBlink())
+                ) {
+                    // Everything is different
+                    sb.append(color(lCell.getForeColor(),
+                            lCell.getBackColor(),
+                            lCell.isBold(), lCell.isReverse(),
+                            lCell.isBlink(),
+                            lCell.isUnderline()));
+
+                    if (debugToStderr) {
+                        System.err.printf("2 Set all attributes\n");
+                    }
+                } else if ((lCell.getForeColor() != lastAttr.getForeColor())
+                    && (lCell.getBackColor() == lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+
+                    // Attributes same, foreColor different
+                    sb.append(color(lCell.isBold(),
+                            lCell.getForeColor(), true));
+
+                    if (debugToStderr) {
+                        System.err.printf("3 Change foreColor\n");
+                    }
+                } else if (lCell.isRGB()
+                    && (lCell.getForeColorRGB() != lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() == lastAttr.getBackColorRGB())
+                    && (lCell.getForeColorRGB() >= 0)
+                    && (lCell.getBackColorRGB() >= 0)
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Attributes same, foreColor different
+                    sb.append(colorRGB(lCell.getForeColorRGB(), true));
+
+                    if (debugToStderr) {
+                        System.err.printf("3 Change foreColor (RGB)\n");
+                    }
+                } else if ((lCell.getForeColor() == lastAttr.getForeColor())
+                    && (lCell.getBackColor() != lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Attributes same, backColor different
+                    sb.append(color(lCell.isBold(),
+                            lCell.getBackColor(), false));
+
+                    if (debugToStderr) {
+                        System.err.printf("4 Change backColor\n");
+                    }
+                } else if (lCell.isRGB()
+                    && (lCell.getForeColorRGB() == lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() != lastAttr.getBackColorRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Attributes same, foreColor different
+                    sb.append(colorRGB(lCell.getBackColorRGB(), false));
+
+                    if (debugToStderr) {
+                        System.err.printf("4 Change backColor (RGB)\n");
+                    }
+                } else if ((lCell.getForeColor() == lastAttr.getForeColor())
+                    && (lCell.getBackColor() == lastAttr.getBackColor())
+                    && (lCell.getForeColorRGB() == lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() == lastAttr.getBackColorRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+
+                    // All attributes the same, just print the char
+                    // NOP
+
+                    if (debugToStderr) {
+                        System.err.printf("5 Only emit character\n");
+                    }
+                } else {
+                    // Just reset everything again
+                    if (!lCell.isRGB()) {
+                        sb.append(color(lCell.getForeColor(),
+                                lCell.getBackColor(),
+                                lCell.isBold(),
+                                lCell.isReverse(),
+                                lCell.isBlink(),
+                                lCell.isUnderline()));
+
+                        if (debugToStderr) {
+                            System.err.printf("6 Change all attributes\n");
+                        }
+                    } else {
+                        sb.append(colorRGB(lCell.getForeColorRGB(),
+                                lCell.getBackColorRGB(),
+                                lCell.isBold(),
+                                lCell.isReverse(),
+                                lCell.isBlink(),
+                                lCell.isUnderline()));
+                        if (debugToStderr) {
+                            System.err.printf("6 Change all attributes (RGB)\n");
+                        }
+                    }
+
+                }
+                // Emit the character
+                if (wideCharImages
+                    // Don't emit the right-half of full-width chars.
+                    || (!wideCharImages
+                        && (lCell.getWidth() != Cell.Width.RIGHT))
+                ) {
+                    sb.append(Character.toChars(lCell.getChar()));
+                }
+
+                // Save the last rendered cell
+                lastX = x;
+                lastAttr.setTo(lCell);
+
+                // Physical is always updated
+                physical[x][y].setTo(lCell);
+
+            } // if (!lCell.equals(pCell) || (reallyCleared == true))
+
+        } // for (int x = 0; x < width; x++)
+    }
+
+    /**
+     * Render the screen to a string that can be emitted to something that
+     * knows how to process ECMA-48/ANSI X3.64 escape sequences.
+     *
+     * @param sb StringBuilder to write escape sequences to
+     * @return escape sequences string that provides the updates to the
+     * physical screen
+     */
+    private String flushString(final StringBuilder sb) {
+        CellAttributes attr = null;
+
+        if (reallyCleared) {
+            attr = new CellAttributes();
+            sb.append(clearAll());
+        }
+
+        /*
+         * For images support, draw all of the image output first, and then
+         * draw everything else afterwards.  This works OK, but performance
+         * is still a drag on larger pictures.
+         */
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                // If physical had non-image data that is now image data, the
+                // entire row must be redrawn.
+                Cell lCell = logical[x][y];
+                Cell pCell = physical[x][y];
+                if (lCell.isImage() && !pCell.isImage()) {
+                    unsetImageRow(y);
+                    break;
+                }
+            }
+        }
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                Cell lCell = logical[x][y];
+                Cell pCell = physical[x][y];
+
+                if (!lCell.isImage()
+                    || (!wideCharImages
+                        && (lCell.getWidth() != Cell.Width.SINGLE))
+                ) {
+                    continue;
+                }
+
+                int left = x;
+                int right = x;
+                while ((right < width)
+                    && (logical[right][y].isImage())
+                    && (!logical[right][y].equals(physical[right][y])
+                        || reallyCleared)
+                ) {
+                    right++;
+                }
+                ArrayList<Cell> cellsToDraw = new ArrayList<Cell>();
+                for (int i = 0; i < (right - x); i++) {
+                    assert (logical[x + i][y].isImage());
+                    cellsToDraw.add(logical[x + i][y]);
+
+                    // Physical is always updated.
+                    physical[x + i][y].setTo(lCell);
+                }
+                if (cellsToDraw.size() > 0) {
+                    if (iterm2Images) {
+                        sb.append(toIterm2Image(x, y, cellsToDraw));
+                    } else if (jexerImageOption != JexerImageOption.DISABLED) {
+                        sb.append(toJexerImage(x, y, cellsToDraw));
+                    } else {
+                        sb.append(toSixel(x, y, cellsToDraw));
+                    }
+                }
+
+                x = right;
+            }
+        }
+
+        // Draw the text part now.
+        for (int y = 0; y < height; y++) {
+            flushLine(y, sb, attr);
+        }
+
+        reallyCleared = false;
+
+        String result = sb.toString();
+        if (debugToStderr) {
+            System.err.printf("flushString(): %s\n", result);
+        }
+        return result;
+    }
+
+    /**
+     * Reset keyboard/mouse input parser.
+     */
+    private void resetParser() {
+        state = ParseState.GROUND;
+        params = new ArrayList<String>();
+        params.clear();
+        params.add("");
+        decPrivateModeFlag = false;
+    }
+
+    /**
+     * Produce a control character or one of the special ones (ENTER, TAB,
+     * etc.).
+     *
+     * @param ch Unicode code point
+     * @param alt if true, set alt on the TKeypress
+     * @return one TKeypress event, either a control character (e.g. isKey ==
+     * false, ch == 'A', ctrl == true), or a special key (e.g. isKey == true,
+     * fnKey == ESC)
+     */
+    private TKeypressEvent controlChar(final char ch, final boolean alt) {
+        // System.err.printf("controlChar: %02x\n", ch);
+
+        switch (ch) {
+        case 0x0D:
+            // Carriage return --> ENTER
+            return new TKeypressEvent(kbEnter, alt, false, false);
+        case 0x0A:
+            // Linefeed --> ENTER
+            return new TKeypressEvent(kbEnter, alt, false, false);
+        case 0x1B:
+            // ESC
+            return new TKeypressEvent(kbEsc, alt, false, false);
+        case '\t':
+            // TAB
+            return new TKeypressEvent(kbTab, alt, false, false);
+        default:
+            // Make all other control characters come back as the alphabetic
+            // character with the ctrl field set.  So SOH would be 'A' +
+            // ctrl.
+            return new TKeypressEvent(false, 0, (char)(ch + 0x40),
+                alt, true, false);
+        }
+    }
+
+    /**
+     * Produce special key from CSI Pn ; Pm ; ... ~
+     *
+     * @return one KEYPRESS event representing a special key
+     */
+    private TInputEvent csiFnKey() {
+        int key = 0;
+        if (params.size() > 0) {
+            key = Integer.parseInt(params.get(0));
+        }
+        boolean alt = false;
+        boolean ctrl = false;
+        boolean shift = false;
+        if (params.size() > 1) {
+            shift = csiIsShift(params.get(1));
+            alt = csiIsAlt(params.get(1));
+            ctrl = csiIsCtrl(params.get(1));
+        }
+
+        switch (key) {
+        case 1:
+            return new TKeypressEvent(kbHome, alt, ctrl, shift);
+        case 2:
+            return new TKeypressEvent(kbIns, alt, ctrl, shift);
+        case 3:
+            return new TKeypressEvent(kbDel, alt, ctrl, shift);
+        case 4:
+            return new TKeypressEvent(kbEnd, alt, ctrl, shift);
+        case 5:
+            return new TKeypressEvent(kbPgUp, alt, ctrl, shift);
+        case 6:
+            return new TKeypressEvent(kbPgDn, alt, ctrl, shift);
+        case 15:
+            return new TKeypressEvent(kbF5, alt, ctrl, shift);
+        case 17:
+            return new TKeypressEvent(kbF6, alt, ctrl, shift);
+        case 18:
+            return new TKeypressEvent(kbF7, alt, ctrl, shift);
+        case 19:
+            return new TKeypressEvent(kbF8, alt, ctrl, shift);
+        case 20:
+            return new TKeypressEvent(kbF9, alt, ctrl, shift);
+        case 21:
+            return new TKeypressEvent(kbF10, alt, ctrl, shift);
+        case 23:
+            return new TKeypressEvent(kbF11, alt, ctrl, shift);
+        case 24:
+            return new TKeypressEvent(kbF12, alt, ctrl, shift);
+        default:
+            // Unknown
+            return null;
+        }
+    }
+
+    /**
+     * Produce mouse events based on "Any event tracking" and UTF-8
+     * coordinates.  See
+     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     *
+     * @return a MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN event
+     */
+    private TInputEvent parseMouse() {
+        int buttons = params.get(0).charAt(0) - 32;
+        int x = params.get(0).charAt(1) - 32 - 1;
+        int y = params.get(0).charAt(2) - 32 - 1;
+
+        // Clamp X and Y to the physical screen coordinates.
+        if (x >= windowResize.getWidth()) {
+            x = windowResize.getWidth() - 1;
+        }
+        if (y >= windowResize.getHeight()) {
+            y = windowResize.getHeight() - 1;
+        }
+
+        TMouseEvent.Type eventType = TMouseEvent.Type.MOUSE_DOWN;
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventMouseWheelUp = false;
+        boolean eventMouseWheelDown = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        // System.err.printf("buttons: %04x\r\n", buttons);
+
+        switch (buttons & 0xE3) {
+        case 0:
+            eventMouse1 = true;
+            mouse1 = true;
+            break;
+        case 1:
+            eventMouse2 = true;
+            mouse2 = true;
+            break;
+        case 2:
+            eventMouse3 = true;
+            mouse3 = true;
+            break;
+        case 3:
+            // Release or Move
+            if (!mouse1 && !mouse2 && !mouse3) {
+                eventType = TMouseEvent.Type.MOUSE_MOTION;
+            } else {
+                eventType = TMouseEvent.Type.MOUSE_UP;
+            }
+            if (mouse1) {
+                mouse1 = false;
+                eventMouse1 = true;
+            }
+            if (mouse2) {
+                mouse2 = false;
+                eventMouse2 = true;
+            }
+            if (mouse3) {
+                mouse3 = false;
+                eventMouse3 = true;
+            }
+            break;
+
+        case 32:
+            // Dragging with mouse1 down
+            eventMouse1 = true;
+            mouse1 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 33:
+            // Dragging with mouse2 down
+            eventMouse2 = true;
+            mouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 34:
+            // Dragging with mouse3 down
+            eventMouse3 = true;
+            mouse3 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 96:
+            // Dragging with mouse2 down after wheelUp
+            eventMouse2 = true;
+            mouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 97:
+            // Dragging with mouse2 down after wheelDown
+            eventMouse2 = true;
+            mouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 64:
+            eventMouseWheelUp = true;
+            break;
+
+        case 65:
+            eventMouseWheelDown = true;
+            break;
+
+        default:
+            // Unknown, just make it motion
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+        }
+
+        if ((buttons & 0x04) != 0) {
+            eventShift = true;
+        }
+        if ((buttons & 0x08) != 0) {
+            eventAlt = true;
+        }
+        if ((buttons & 0x10) != 0) {
+            eventCtrl = true;
+        }
+
+        return new TMouseEvent(eventType, x, y, x, y,
+            eventMouse1, eventMouse2, eventMouse3,
+            eventMouseWheelUp, eventMouseWheelDown,
+            eventAlt, eventCtrl, eventShift);
+    }
+
+    /**
+     * Produce mouse events based on "Any event tracking" and SGR
+     * coordinates.  See
+     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     *
+     * @param release if true, this was a release ('m')
+     * @return a MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN event
+     */
+    private TInputEvent parseMouseSGR(final boolean release) {
+        // SGR extended coordinates - mode 1006
+        if (params.size() < 3) {
+            // Invalid position, bail out.
+            return null;
+        }
+        int buttons = Integer.parseInt(params.get(0));
+        int x = Integer.parseInt(params.get(1)) - 1;
+        int y = Integer.parseInt(params.get(2)) - 1;
+
+        // Clamp X and Y to the physical screen coordinates.
+        if (x >= windowResize.getWidth()) {
+            x = windowResize.getWidth() - 1;
+        }
+        if (y >= windowResize.getHeight()) {
+            y = windowResize.getHeight() - 1;
+        }
+
+        TMouseEvent.Type eventType = TMouseEvent.Type.MOUSE_DOWN;
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventMouseWheelUp = false;
+        boolean eventMouseWheelDown = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        if (release) {
+            eventType = TMouseEvent.Type.MOUSE_UP;
+        }
+
+        switch (buttons & 0xE3) {
+        case 0:
+            eventMouse1 = true;
+            break;
+        case 1:
+            eventMouse2 = true;
+            break;
+        case 2:
+            eventMouse3 = true;
+            break;
+        case 35:
+            // Motion only, no buttons down
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 32:
+            // Dragging with mouse1 down
+            eventMouse1 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 33:
+            // Dragging with mouse2 down
+            eventMouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 34:
+            // Dragging with mouse3 down
+            eventMouse3 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 96:
+            // Dragging with mouse2 down after wheelUp
+            eventMouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 97:
+            // Dragging with mouse2 down after wheelDown
+            eventMouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 64:
+            eventMouseWheelUp = true;
+            break;
+
+        case 65:
+            eventMouseWheelDown = true;
+            break;
+
+        default:
+            // Unknown, bail out
+            return null;
+        }
+
+        if ((buttons & 0x04) != 0) {
+            eventShift = true;
+        }
+        if ((buttons & 0x08) != 0) {
+            eventAlt = true;
+        }
+        if ((buttons & 0x10) != 0) {
+            eventCtrl = true;
+        }
+
+        return new TMouseEvent(eventType, x, y, x, y,
+            eventMouse1, eventMouse2, eventMouse3,
+            eventMouseWheelUp, eventMouseWheelDown,
+            eventAlt, eventCtrl, eventShift);
+    }
+
+    /**
+     * Return any events in the IO queue due to timeout.
+     *
+     * @param queue list to append new events to
+     */
+    private void getIdleEvents(final List<TInputEvent> queue) {
+        long nowTime = System.currentTimeMillis();
+
+        // Check for new window size
+        long windowSizeDelay = nowTime - windowSizeTime;
+        if (windowSizeDelay > 1000) {
+            int oldTextWidth = getTextWidth();
+            int oldTextHeight = getTextHeight();
+
+            sessionInfo.queryWindowSize();
+            int newWidth = sessionInfo.getWindowWidth();
+            int newHeight = sessionInfo.getWindowHeight();
+
+            if ((newWidth != windowResize.getWidth())
+                || (newHeight != windowResize.getHeight())
+            ) {
+
+                // Request xterm report window dimensions in pixels again.
+                // Between now and then, ensure that the reported text cell
+                // size is the same by setting widthPixels and heightPixels
+                // to match the new dimensions.
+                widthPixels = oldTextWidth * newWidth;
+                heightPixels = oldTextHeight * newHeight;
+
+                if (debugToStderr) {
+                    System.err.println("Screen size changed, old size " +
+                        windowResize);
+                    System.err.println("                     new size " +
+                        newWidth + " x " + newHeight);
+                    System.err.println("                   old pixels " +
+                        oldTextWidth + " x " + oldTextHeight);
+                    System.err.println("                   new pixels " +
+                        getTextWidth() + " x " + getTextHeight());
+                }
+
+                this.output.printf("%s", xtermReportPixelDimensions());
+                this.output.flush();
+
+                TResizeEvent event = new TResizeEvent(TResizeEvent.Type.SCREEN,
+                    newWidth, newHeight);
+                windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+                    newWidth, newHeight);
+                queue.add(event);
+            }
+            windowSizeTime = nowTime;
+        }
+
+        // ESCDELAY type timeout
+        if (state == ParseState.ESCAPE) {
+            long escDelay = nowTime - escapeTime;
+            if (escDelay > 100) {
+                // After 0.1 seconds, assume a true escape character
+                queue.add(controlChar((char)0x1B, false));
+                resetParser();
+            }
+        }
+    }
+
+    /**
+     * Returns true if the CSI parameter for a keyboard command means that
+     * shift was down.
+     */
+    private boolean csiIsShift(final String x) {
+        if ((x.equals("2"))
+            || (x.equals("4"))
+            || (x.equals("6"))
+            || (x.equals("8"))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the CSI parameter for a keyboard command means that
+     * alt was down.
+     */
+    private boolean csiIsAlt(final String x) {
+        if ((x.equals("3"))
+            || (x.equals("4"))
+            || (x.equals("7"))
+            || (x.equals("8"))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the CSI parameter for a keyboard command means that
+     * ctrl was down.
+     */
+    private boolean csiIsCtrl(final String x) {
+        if ((x.equals("5"))
+            || (x.equals("6"))
+            || (x.equals("7"))
+            || (x.equals("8"))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Parses the next character of input to see if an InputEvent is
+     * fully here.
+     *
+     * @param events list to append new events to
+     * @param ch Unicode code point
+     */
+    private void processChar(final List<TInputEvent> events, final char ch) {
+
+        // ESCDELAY type timeout
+        long nowTime = System.currentTimeMillis();
+        if (state == ParseState.ESCAPE) {
+            long escDelay = nowTime - escapeTime;
+            if (escDelay > 250) {
+                // After 0.25 seconds, assume a true escape character
+                events.add(controlChar((char)0x1B, false));
+                resetParser();
+            }
+        }
+
+        // TKeypress fields
+        boolean ctrl = false;
+        boolean alt = false;
+        boolean shift = false;
+
+        // System.err.printf("state: %s ch %c\r\n", state, ch);
+
+        switch (state) {
+        case GROUND:
+
+            if (ch == 0x1B) {
+                state = ParseState.ESCAPE;
+                escapeTime = nowTime;
+                return;
+            }
+
+            if (ch <= 0x1F) {
+                // Control character
+                events.add(controlChar(ch, false));
+                resetParser();
+                return;
+            }
+
+            if (ch >= 0x20) {
+                // Normal character
+                events.add(new TKeypressEvent(false, 0, ch,
+                        false, false, false));
+                resetParser();
+                return;
+            }
+
+            break;
+
+        case ESCAPE:
+            if (ch <= 0x1F) {
+                // ALT-Control character
+                events.add(controlChar(ch, true));
+                resetParser();
+                return;
+            }
+
+            if (ch == 'O') {
+                // This will be one of the function keys
+                state = ParseState.ESCAPE_INTERMEDIATE;
+                return;
+            }
+
+            // '[' goes to CSI_ENTRY
+            if (ch == '[') {
+                state = ParseState.CSI_ENTRY;
+                return;
+            }
+
+            // Everything else is assumed to be Alt-keystroke
+            if ((ch >= 'A') && (ch <= 'Z')) {
+                shift = true;
+            }
+            alt = true;
+            events.add(new TKeypressEvent(false, 0, ch, alt, ctrl, shift));
+            resetParser();
+            return;
+
+        case ESCAPE_INTERMEDIATE:
+            if ((ch >= 'P') && (ch <= 'S')) {
+                // Function key
+                switch (ch) {
+                case 'P':
+                    events.add(new TKeypressEvent(kbF1));
+                    break;
+                case 'Q':
+                    events.add(new TKeypressEvent(kbF2));
+                    break;
+                case 'R':
+                    events.add(new TKeypressEvent(kbF3));
+                    break;
+                case 'S':
+                    events.add(new TKeypressEvent(kbF4));
+                    break;
+                default:
+                    break;
+                }
+                resetParser();
+                return;
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case CSI_ENTRY:
+            // Numbers - parameter values
+            if ((ch >= '0') && (ch <= '9')) {
+                params.set(params.size() - 1,
+                    params.get(params.size() - 1) + ch);
+                state = ParseState.CSI_PARAM;
+                return;
+            }
+            // Parameter separator
+            if (ch == ';') {
+                params.add("");
+                return;
+            }
+
+            if ((ch >= 0x30) && (ch <= 0x7E)) {
+                switch (ch) {
+                case 'A':
+                    // Up
+                    events.add(new TKeypressEvent(kbUp, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'B':
+                    // Down
+                    events.add(new TKeypressEvent(kbDown, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'C':
+                    // Right
+                    events.add(new TKeypressEvent(kbRight, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'D':
+                    // Left
+                    events.add(new TKeypressEvent(kbLeft, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'H':
+                    // Home
+                    events.add(new TKeypressEvent(kbHome));
+                    resetParser();
+                    return;
+                case 'F':
+                    // End
+                    events.add(new TKeypressEvent(kbEnd));
+                    resetParser();
+                    return;
+                case 'Z':
+                    // CBT - Cursor backward X tab stops (default 1)
+                    events.add(new TKeypressEvent(kbBackTab));
+                    resetParser();
+                    return;
+                case 'M':
+                    // Mouse position
+                    state = ParseState.MOUSE;
+                    return;
+                case '<':
+                    // Mouse position, SGR (1006) coordinates
+                    state = ParseState.MOUSE_SGR;
+                    return;
+                case '?':
+                    // DEC private mode flag
+                    decPrivateModeFlag = true;
+                    return;
+                default:
+                    break;
+                }
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case MOUSE_SGR:
+            // Numbers - parameter values
+            if ((ch >= '0') && (ch <= '9')) {
+                params.set(params.size() - 1,
+                    params.get(params.size() - 1) + ch);
+                return;
+            }
+            // Parameter separator
+            if (ch == ';') {
+                params.add("");
+                return;
+            }
+
+            switch (ch) {
+            case 'M':
+                // Generate a mouse press event
+                TInputEvent event = parseMouseSGR(false);
+                if (event != null) {
+                    events.add(event);
+                }
+                resetParser();
+                return;
+            case 'm':
+                // Generate a mouse release event
+                event = parseMouseSGR(true);
+                if (event != null) {
+                    events.add(event);
+                }
+                resetParser();
+                return;
+            default:
+                break;
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case CSI_PARAM:
+            // Numbers - parameter values
+            if ((ch >= '0') && (ch <= '9')) {
+                params.set(params.size() - 1,
+                    params.get(params.size() - 1) + ch);
+                state = ParseState.CSI_PARAM;
+                return;
+            }
+            // Parameter separator
+            if (ch == ';') {
+                params.add("");
+                return;
+            }
+
+            if (ch == '~') {
+                events.add(csiFnKey());
+                resetParser();
+                return;
+            }
+
+            if ((ch >= 0x30) && (ch <= 0x7E)) {
+                switch (ch) {
+                case 'A':
+                    // Up
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbUp, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'B':
+                    // Down
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbDown, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'C':
+                    // Right
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbRight, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'D':
+                    // Left
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbLeft, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'H':
+                    // Home
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbHome, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'F':
+                    // End
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbEnd, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'c':
+                    // Device Attributes
+                    if (decPrivateModeFlag == false) {
+                        break;
+                    }
+                    boolean reportsJexerImages = false;
+                    boolean reportsIterm2Images = false;
+                    for (String x: params) {
+                        if (x.equals("4")) {
+                            // Terminal reports sixel support
+                            if (debugToStderr) {
+                                System.err.println("Device Attributes: sixel");
+                            }
+                        }
+                        if (x.equals("444")) {
+                            // Terminal reports Jexer images support
+                            if (debugToStderr) {
+                                System.err.println("Device Attributes: Jexer images");
+                            }
+                            reportsJexerImages = true;
+                        }
+                        if (x.equals("1337")) {
+                            // Terminal reports iTerm2 images support
+                            if (debugToStderr) {
+                                System.err.println("Device Attributes: iTerm2 images");
+                            }
+                            reportsIterm2Images = true;
+                        }
+                    }
+                    if (reportsJexerImages == false) {
+                        // Terminal does not support Jexer images, disable
+                        // them.
+                        jexerImageOption = JexerImageOption.DISABLED;
+                    }
+                    if (reportsIterm2Images == false) {
+                        // Terminal does not support iTerm2 images, disable
+                        // them.
+                        iterm2Images = false;
+                    }
+                    resetParser();
+                    return;
+                case 't':
+                    // windowOps
+                    if ((params.size() > 2) && (params.get(0).equals("4"))) {
+                        if (debugToStderr) {
+                            System.err.printf("windowOp pixels: " +
+                                "height %s width %s\n",
+                                params.get(1), params.get(2));
+                        }
+                        try {
+                            widthPixels = Integer.parseInt(params.get(2));
+                            heightPixels = Integer.parseInt(params.get(1));
+                        } catch (NumberFormatException e) {
+                            if (debugToStderr) {
+                                e.printStackTrace();
+                            }
+                        }
+                        if (widthPixels <= 0) {
+                            widthPixels = 640;
+                        }
+                        if (heightPixels <= 0) {
+                            heightPixels = 400;
+                        }
+                    }
+                    if ((params.size() > 2) && (params.get(0).equals("6"))) {
+                        if (debugToStderr) {
+                            System.err.printf("windowOp text cell pixels: " +
+                                "height %s width %s\n",
+                                params.get(1), params.get(2));
+                        }
+                        try {
+                            widthPixels = width * Integer.parseInt(params.get(2));
+                            heightPixels = height * Integer.parseInt(params.get(1));
+                        } catch (NumberFormatException e) {
+                            if (debugToStderr) {
+                                e.printStackTrace();
+                            }
+                        }
+                        if (widthPixels <= 0) {
+                            widthPixels = 640;
+                        }
+                        if (heightPixels <= 0) {
+                            heightPixels = 400;
+                        }
+                    }
+                    resetParser();
+                    return;
+                default:
+                    break;
+                }
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case MOUSE:
+            params.set(0, params.get(params.size() - 1) + ch);
+            if (params.get(0).length() == 3) {
+                // We have enough to generate a mouse event
+                events.add(parseMouse());
+                resetParser();
+            }
+            return;
+
+        default:
+            break;
+        }
+
+        // This "should" be impossible to reach
+        return;
+    }
+
+    /**
+     * Request (u)xterm to use the sixel settings we need:
+     *
+     *   - enable sixel scrolling
+     *
+     *   - disable private color registers (so that we can use one common
+     *     palette) if sixelSharedPalette is set
+     *
+     * @return the string to emit to xterm
+     */
+    private String xtermSetSixelSettings() {
+        if (sixelSharedPalette == true) {
+            return "\033[?80h\033[?1070l";
+        } else {
+            return "\033[?80h\033[?1070h";
+        }
+    }
+
+    /**
+     * Restore (u)xterm its default sixel settings:
+     *
+     *   - enable sixel scrolling
+     *
+     *   - enable private color registers
+     *
+     * @return the string to emit to xterm
+     */
+    private String xtermResetSixelSettings() {
+        return "\033[?80h\033[?1070h";
+    }
+
+    /**
+     * Request (u)xterm to report the current window and cell size dimensions
+     * in pixels.
+     *
+     * @return the string to emit to xterm
+     */
+    private String xtermReportPixelDimensions() {
+        // We will ask for both window and text cell dimensions, and
+        // hopefully one of them will work.
+        return "\033[14t\033[16t";
+    }
+
+    /**
+     * Tell (u)xterm that we want alt- keystrokes to send escape + character
+     * rather than set the 8th bit.  Anyone who wants UTF8 should want this
+     * enabled.
+     *
+     * @param on if true, enable metaSendsEscape
+     * @return the string to emit to xterm
+     */
+    private String xtermMetaSendsEscape(final boolean on) {
+        if (on) {
+            return "\033[?1036h\033[?1034l";
+        }
+        return "\033[?1036l";
+    }
+
+    /**
+     * Create an xterm OSC sequence to change the window title.
+     *
+     * @param title the new title
+     * @return the string to emit to xterm
+     */
+    private String getSetTitleString(final String title) {
+        return "\033]2;" + title + "\007";
+    }
+
+    // ------------------------------------------------------------------------
+    // Sixel output support ---------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the number of colors in the sixel palette.
+     *
+     * @return the palette size
+     */
+    public int getSixelPaletteSize() {
+        return sixelPaletteSize;
+    }
+
+    /**
+     * Set the number of colors in the sixel palette.
+     *
+     * @param paletteSize the new palette size
+     */
+    public void setSixelPaletteSize(final int paletteSize) {
+        if (paletteSize == sixelPaletteSize) {
+            return;
+        }
+
+        switch (paletteSize) {
+        case 2:
+        case 256:
+        case 512:
+        case 1024:
+        case 2048:
+            break;
+        default:
+            throw new IllegalArgumentException("Unsupported sixel palette " +
+                " size: " + paletteSize);
+        }
+
+        // Don't step on the screen refresh thread.
+        synchronized (this) {
+            sixelPaletteSize = paletteSize;
+            palette = null;
+            sixelCache = null;
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Start a sixel string for display one row's worth of bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String startSixel(final int x, final int y) {
+        StringBuilder sb = new StringBuilder();
+
+        assert (sixel == true);
+
+        // Place the cursor
+        sb.append(gotoXY(x, y));
+
+        // DCS
+        sb.append("\033Pq");
+
+        if (palette == null) {
+            palette = new SixelPalette();
+            if (sixelSharedPalette == true) {
+                palette.emitPalette(sb, null);
+            }
+        }
+
+        return sb.toString();
+    }
+
+    /**
+     * End a sixel string for display one row's worth of bitmap data.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String endSixel() {
+        assert (sixel == true);
+
+        // ST
+        return ("\033\\");
+    }
+
+    /**
+     * Create a sixel string representing a row of several cells containing
+     * bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cells the cells containing the bitmap data
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String toSixel(final int x, final int y,
+        final ArrayList<Cell> cells) {
+
+        StringBuilder sb = new StringBuilder();
+
+        assert (cells != null);
+        assert (cells.size() > 0);
+        assert (cells.get(0).getImage() != null);
+
+        if (sixel == false) {
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int i = 0; i < cells.size(); i++) {
+                sb.append(' ');
+            }
+            return sb.toString();
+        }
+
+        if (y == height - 1) {
+            // We are on the bottom row.  If scrolling mode is enabled
+            // (default), then VT320/xterm will scroll the entire screen if
+            // we draw any pixels here.  Do not draw the image, bail out
+            // instead.
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int j = 0; j < cells.size(); j++) {
+                sb.append(' ');
+            }
+            return sb.toString();
+        }
+
+        if (sixelCache == null) {
+            sixelCache = new ImageCache(height * 10);
+        }
+
+        // Save and get rows to/from the cache that do NOT have inverted
+        // cells.
+        boolean saveInCache = true;
+        for (Cell cell: cells) {
+            if (cell.isInvertedImage()) {
+                saveInCache = false;
+            }
+        }
+        if (saveInCache) {
+            String cachedResult = sixelCache.get(cells);
+            if (cachedResult != null) {
+                // System.err.println("CACHE HIT");
+                sb.append(startSixel(x, y));
+                sb.append(cachedResult);
+                sb.append(endSixel());
+                return sb.toString();
+            }
+            // System.err.println("CACHE MISS");
+        }
+
+        BufferedImage image = cellsToImage(cells);
+        int fullHeight = image.getHeight();
+
+        // Dither the image.  It is ok to lose the original here.
+        if (palette == null) {
+            palette = new SixelPalette();
+            if (sixelSharedPalette == true) {
+                palette.emitPalette(sb, null);
+            }
+        }
+        image = palette.ditherImage(image);
+
+        // Collect the raster information
+        int rasterHeight = 0;
+        int rasterWidth = image.getWidth();
+
+        if (sixelSharedPalette == false) {
+            // Emit the palette, but only for the colors actually used by
+            // these cells.
+            boolean [] usedColors = new boolean[sixelPaletteSize];
+            for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                for (int imageY = 0; imageY < image.getHeight(); imageY++) {
+                    usedColors[image.getRGB(imageX, imageY)] = true;
+                }
+            }
+            palette.emitPalette(sb, usedColors);
+        }
+
+        // Render the entire row of cells.
+        for (int currentRow = 0; currentRow < fullHeight; currentRow += 6) {
+            int [][] sixels = new int[image.getWidth()][6];
+
+            // See which colors are actually used in this band of sixels.
+            for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                for (int imageY = 0;
+                     (imageY < 6) && (imageY + currentRow < fullHeight);
+                     imageY++) {
+
+                    int colorIdx = image.getRGB(imageX, imageY + currentRow);
+                    assert (colorIdx >= 0);
+                    assert (colorIdx < sixelPaletteSize);
+
+                    sixels[imageX][imageY] = colorIdx;
+                }
+            }
+
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                boolean isUsed = false;
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                    for (int j = 0; j < 6; j++) {
+                        if (sixels[imageX][j] == i) {
+                            isUsed = true;
+                        }
+                    }
+                }
+                if (isUsed == false) {
+                    continue;
+                }
+
+                // Set to the beginning of scan line for the next set of
+                // colored pixels, and select the color.
+                sb.append(String.format("$#%d", i));
+
+                int oldData = -1;
+                int oldDataCount = 0;
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+
+                    // Add up all the pixels that match this color.
+                    int data = 0;
+                    for (int j = 0;
+                         (j < 6) && (currentRow + j < fullHeight);
+                         j++) {
+
+                        if (sixels[imageX][j] == i) {
+                            switch (j) {
+                            case 0:
+                                data += 1;
+                                break;
+                            case 1:
+                                data += 2;
+                                break;
+                            case 2:
+                                data += 4;
+                                break;
+                            case 3:
+                                data += 8;
+                                break;
+                            case 4:
+                                data += 16;
+                                break;
+                            case 5:
+                                data += 32;
+                                break;
+                            }
+                            if ((currentRow + j + 1) > rasterHeight) {
+                                rasterHeight = currentRow + j + 1;
+                            }
+                        }
+                    }
+                    assert (data >= 0);
+                    assert (data < 64);
+                    data += 63;
+
+                    if (data == oldData) {
+                        oldDataCount++;
+                    } else {
+                        if (oldDataCount == 1) {
+                            sb.append((char) oldData);
+                        } else if (oldDataCount > 1) {
+                            sb.append(String.format("!%d", oldDataCount));
+                            sb.append((char) oldData);
+                        }
+                        oldDataCount = 1;
+                        oldData = data;
+                    }
+
+                } // for (int imageX = 0; imageX < image.getWidth(); imageX++)
+
+                // Emit the last sequence.
+                if (oldDataCount == 1) {
+                    sb.append((char) oldData);
+                } else if (oldDataCount > 1) {
+                    sb.append(String.format("!%d", oldDataCount));
+                    sb.append((char) oldData);
+                }
+
+            } // for (int i = 0; i < sixelPaletteSize; i++)
+
+            // Advance to the next scan line.
+            sb.append("-");
+
+        } // for (int currentRow = 0; currentRow < imageHeight; currentRow += 6)
+
+        // Kill the very last "-", because it is unnecessary.
+        sb.deleteCharAt(sb.length() - 1);
+
+        // Add the raster information
+        sb.insert(0, String.format("\"1;1;%d;%d", rasterWidth, rasterHeight));
+
+        if (saveInCache) {
+            // This row is OK to save into the cache.
+            sixelCache.put(cells, sb.toString());
+        }
+
+        return (startSixel(x, y) + sb.toString() + endSixel());
+    }
+
+    /**
+     * Get the sixel support flag.
+     *
+     * @return true if this terminal is emitting sixel
+     */
+    public boolean hasSixel() {
+        return sixel;
+    }
+
+    /**
+     * Convert a horizontal range of cell's image data into a single
+     * contigous image, rescaled and anti-aliased to match the current text
+     * cell size.
+     *
+     * @param cells the cells containing image data
+     * @return the image resized to the current text cell size
+     */
+    private BufferedImage cellsToImage(final List<Cell> cells) {
+        int imageWidth = cells.get(0).getImage().getWidth();
+        int imageHeight = cells.get(0).getImage().getHeight();
+
+        // Piece cells.get(x).getImage() pieces together into one larger
+        // image for final rendering.
+        int totalWidth = 0;
+        int fullWidth = cells.size() * imageWidth;
+        int fullHeight = imageHeight;
+        for (int i = 0; i < cells.size(); i++) {
+            totalWidth += cells.get(i).getImage().getWidth();
+        }
+
+        BufferedImage image = new BufferedImage(fullWidth,
+            fullHeight, BufferedImage.TYPE_INT_ARGB);
+
+        int [] rgbArray;
+        for (int i = 0; i < cells.size() - 1; i++) {
+            int tileWidth = imageWidth;
+            int tileHeight = imageHeight;
+
+            if (false && cells.get(i).isInvertedImage()) {
+                // I used to put an all-white cell over the cursor, don't do
+                // that anymore.
+                rgbArray = new int[imageWidth * imageHeight];
+                for (int j = 0; j < rgbArray.length; j++) {
+                    rgbArray[j] = 0xFFFFFF;
+                }
+            } else {
+                try {
+                    rgbArray = cells.get(i).getImage().getRGB(0, 0,
+                        tileWidth, tileHeight, null, 0, tileWidth);
+                } catch (Exception e) {
+                    throw new RuntimeException("image " + imageWidth + "x" +
+                        imageHeight +
+                        "tile " + tileWidth + "x" +
+                        tileHeight +
+                        " cells.get(i).getImage() " +
+                        cells.get(i).getImage() +
+                        " i " + i +
+                        " fullWidth " + fullWidth +
+                        " fullHeight " + fullHeight, e);
+                }
+            }
+
+            /*
+            System.err.printf("calling image.setRGB(): %d %d %d %d %d\n",
+                i * imageWidth, 0, imageWidth, imageHeight,
+                0, imageWidth);
+            System.err.printf("   fullWidth %d fullHeight %d cells.size() %d textWidth %d\n",
+                fullWidth, fullHeight, cells.size(), getTextWidth());
+             */
+
+            image.setRGB(i * imageWidth, 0, tileWidth, tileHeight,
+                rgbArray, 0, tileWidth);
+            if (tileHeight < fullHeight) {
+                int backgroundColor = cells.get(i).getBackground().getRGB();
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                    for (int imageY = imageHeight; imageY < fullHeight;
+                         imageY++) {
+
+                        image.setRGB(imageX, imageY, backgroundColor);
+                    }
+                }
+            }
+        }
+        totalWidth -= ((cells.size() - 1) * imageWidth);
+        if (false && cells.get(cells.size() - 1).isInvertedImage()) {
+            // I used to put an all-white cell over the cursor, don't do that
+            // anymore.
+            rgbArray = new int[totalWidth * imageHeight];
+            for (int j = 0; j < rgbArray.length; j++) {
+                rgbArray[j] = 0xFFFFFF;
+            }
+        } else {
+            try {
+                rgbArray = cells.get(cells.size() - 1).getImage().getRGB(0, 0,
+                    totalWidth, imageHeight, null, 0, totalWidth);
+            } catch (Exception e) {
+                throw new RuntimeException("image " + imageWidth + "x" +
+                    imageHeight + " cells.get(cells.size() - 1).getImage() " +
+                    cells.get(cells.size() - 1).getImage(), e);
+            }
+        }
+        image.setRGB((cells.size() - 1) * imageWidth, 0, totalWidth,
+            imageHeight, rgbArray, 0, totalWidth);
+
+        if (totalWidth < imageWidth) {
+            int backgroundColor = cells.get(cells.size() - 1).getBackground().getRGB();
+
+            for (int imageX = image.getWidth() - totalWidth;
+                 imageX < image.getWidth(); imageX++) {
+
+                for (int imageY = 0; imageY < fullHeight; imageY++) {
+                    image.setRGB(imageX, imageY, backgroundColor);
+                }
+            }
+        }
+
+        if ((image.getWidth() != cells.size() * getTextWidth())
+            || (image.getHeight() != getTextHeight())
+        ) {
+            // Rescale the image to fit the text cells it is going into.
+            BufferedImage newImage;
+            newImage = new BufferedImage(cells.size() * getTextWidth(),
+                getTextHeight(), BufferedImage.TYPE_INT_ARGB);
+
+            Graphics gr = newImage.getGraphics();
+            if (gr instanceof Graphics2D) {
+                ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                    RenderingHints.VALUE_ANTIALIAS_ON);
+                ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                    RenderingHints.VALUE_RENDER_QUALITY);
+            }
+            gr.drawImage(image, 0, 0, newImage.getWidth(),
+                newImage.getHeight(), null, null);
+            gr.dispose();
+            image = newImage;
+        }
+
+        return image;
+    }
+
+    // ------------------------------------------------------------------------
+    // End sixel output support -----------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // iTerm2 image output support --------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create an iTerm2 images string representing a row of several cells
+     * containing bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cells the cells containing the bitmap data
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String toIterm2Image(final int x, final int y,
+        final ArrayList<Cell> cells) {
+
+        StringBuilder sb = new StringBuilder();
+
+        assert (cells != null);
+        assert (cells.size() > 0);
+        assert (cells.get(0).getImage() != null);
+
+        if (iterm2Images == false) {
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int i = 0; i < cells.size(); i++) {
+                sb.append(' ');
+            }
+            return sb.toString();
+        }
+
+        if (iterm2Cache == null) {
+            iterm2Cache = new ImageCache(height * 10);
+        }
+
+        // Save and get rows to/from the cache that do NOT have inverted
+        // cells.
+        boolean saveInCache = true;
+        for (Cell cell: cells) {
+            if (cell.isInvertedImage()) {
+                saveInCache = false;
+            }
+        }
+        if (saveInCache) {
+            String cachedResult = iterm2Cache.get(cells);
+            if (cachedResult != null) {
+                // System.err.println("CACHE HIT");
+                sb.append(gotoXY(x, y));
+                sb.append(cachedResult);
+                return sb.toString();
+            }
+            // System.err.println("CACHE MISS");
+        }
+
+        BufferedImage image = cellsToImage(cells);
+        int fullHeight = image.getHeight();
+
+        /*
+         * From https://iterm2.com/documentation-images.html:
+         *
+         * Protocol
+         *
+         * iTerm2 extends the xterm protocol with a set of proprietary escape
+         * sequences. In general, the pattern is:
+         *
+         * ESC ] 1337 ; key = value ^G
+         *
+         * Whitespace is shown here for ease of reading: in practice, no
+         * spaces should be used.
+         *
+         * For file transfer and inline images, the code is:
+         *
+         * ESC ] 1337 ; File = [optional arguments] : base-64 encoded file contents ^G
+         *
+         * The optional arguments are formatted as key=value with a semicolon
+         * between each key-value pair. They are described below:
+         *
+         * Key         Description of value
+         * name         base-64 encoded filename. Defaults to "Unnamed file".
+         * size         File size in bytes. Optional; this is only used by the
+         *              progress indicator.
+         * width        Width to render. See notes below.
+         * height       Height to render. See notes below.
+         * preserveAspectRatio If set to 0, then the image's inherent aspect
+         *                     ratio will not be respected; otherwise, it
+         *                     will fill the specified width and height as
+         *                     much as possible without stretching. Defaults
+         *                     to 1.
+         * inline If set to 1, the file will be displayed inline. Otherwise,
+         *        it will be downloaded with no visual representation in the
+         *        terminal session. Defaults to 0.
+         *
+         * The width and height are given as a number followed by a unit, or
+         * the word "auto".
+         *
+         * N: N character cells.
+         * Npx: N pixels.
+         * N%: N percent of the session's width or height.
+         * auto: The image's inherent size will be used to determine an
+         *       appropriate dimension.
+         *
+         */
+
+        // File contents can be several image formats.  We will use PNG.
+        ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream(1024);
+        try {
+            if (!ImageIO.write(image.getSubimage(0, 0, image.getWidth(),
+                        Math.min(image.getHeight(), fullHeight)),
+                    "PNG", pngOutputStream)
+            ) {
+                // We failed to render image, bail out.
+                return "";
+            }
+        } catch (IOException e) {
+            // We failed to render image, bail out.
+            return "";
+        }
+
+        sb.append("\033]1337;File=");
+        /*
+        sb.append(String.format("width=$d;height=1;preserveAspectRatio=1;",
+                cells.size()));
+         */
+        /*
+        sb.append(String.format("width=$dpx;height=%dpx;preserveAspectRatio=1;",
+                image.getWidth(), Math.min(image.getHeight(),
+                    getTextHeight())));
+         */
+        sb.append("inline=1:");
+        sb.append(StringUtils.toBase64(pngOutputStream.toByteArray()));
+        sb.append("\007");
+
+        if (saveInCache) {
+            // This row is OK to save into the cache.
+            iterm2Cache.put(cells, sb.toString());
+        }
+
+        return (gotoXY(x, y) + sb.toString());
+    }
+
+    /**
+     * Get the iTerm2 images support flag.
+     *
+     * @return true if this terminal is emitting iTerm2 images
+     */
+    public boolean hasIterm2Images() {
+        return iterm2Images;
+    }
+
+    // ------------------------------------------------------------------------
+    // End iTerm2 image output support ----------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Jexer image output support ---------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create a Jexer images string representing a row of several cells
+     * containing bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cells the cells containing the bitmap data
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String toJexerImage(final int x, final int y,
+        final ArrayList<Cell> cells) {
+
+        StringBuilder sb = new StringBuilder();
+
+        assert (cells != null);
+        assert (cells.size() > 0);
+        assert (cells.get(0).getImage() != null);
+
+        if (jexerImageOption == JexerImageOption.DISABLED) {
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int i = 0; i < cells.size(); i++) {
+                sb.append(' ');
+            }
+            return sb.toString();
+        }
+
+        if (jexerCache == null) {
+            jexerCache = new ImageCache(height * 10);
+        }
+
+        // Save and get rows to/from the cache that do NOT have inverted
+        // cells.
+        boolean saveInCache = true;
+        for (Cell cell: cells) {
+            if (cell.isInvertedImage()) {
+                saveInCache = false;
+            }
+        }
+        if (saveInCache) {
+            String cachedResult = jexerCache.get(cells);
+            if (cachedResult != null) {
+                // System.err.println("CACHE HIT");
+                sb.append(gotoXY(x, y));
+                sb.append(cachedResult);
+                return sb.toString();
+            }
+            // System.err.println("CACHE MISS");
+        }
+
+        BufferedImage image = cellsToImage(cells);
+        int fullHeight = image.getHeight();
+
+        if (jexerImageOption == JexerImageOption.PNG) {
+            // Encode as PNG
+            ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream(1024);
+            try {
+                if (!ImageIO.write(image.getSubimage(0, 0, image.getWidth(),
+                            Math.min(image.getHeight(), fullHeight)),
+                        "PNG", pngOutputStream)
+                ) {
+                    // We failed to render image, bail out.
+                    return "";
+                }
+            } catch (IOException e) {
+                // We failed to render image, bail out.
+                return "";
+            }
+
+            sb.append("\033]444;1;0;");
+            sb.append(StringUtils.toBase64(pngOutputStream.toByteArray()));
+            sb.append("\007");
+
+        } else if (jexerImageOption == JexerImageOption.JPG) {
+
+            // Encode as JPG
+            ByteArrayOutputStream jpgOutputStream = new ByteArrayOutputStream(1024);
+
+            // Convert from ARGB to RGB, otherwise the JPG encode will fail.
+            BufferedImage jpgImage = new BufferedImage(image.getWidth(),
+                image.getHeight(), BufferedImage.TYPE_INT_RGB);
+            int [] pixels = new int[image.getWidth() * image.getHeight()];
+            image.getRGB(0, 0, image.getWidth(), image.getHeight(), pixels,
+                0, image.getWidth());
+            jpgImage.setRGB(0, 0, image.getWidth(), image.getHeight(), pixels,
+                0, image.getWidth());
+
+            try {
+                if (!ImageIO.write(jpgImage.getSubimage(0, 0,
+                            jpgImage.getWidth(),
+                            Math.min(jpgImage.getHeight(), fullHeight)),
+                        "JPG", jpgOutputStream)
+                ) {
+                    // We failed to render image, bail out.
+                    return "";
+                }
+            } catch (IOException e) {
+                // We failed to render image, bail out.
+                return "";
+            }
+
+            sb.append("\033]444;2;0;");
+            sb.append(StringUtils.toBase64(jpgOutputStream.toByteArray()));
+            sb.append("\007");
+
+        } else if (jexerImageOption == JexerImageOption.RGB) {
+
+            // RGB
+            sb.append(String.format("\033]444;0;%d;%d;0;", image.getWidth(),
+                    Math.min(image.getHeight(), fullHeight)));
+
+            byte [] bytes = new byte[image.getWidth() * image.getHeight() * 3];
+            int stride = image.getWidth();
+            for (int px = 0; px < stride; px++) {
+                for (int py = 0; py < image.getHeight(); py++) {
+                    int rgb = image.getRGB(px, py);
+                    bytes[(py * stride * 3) + (px * 3)]     = (byte) ((rgb >>> 16) & 0xFF);
+                    bytes[(py * stride * 3) + (px * 3) + 1] = (byte) ((rgb >>>  8) & 0xFF);
+                    bytes[(py * stride * 3) + (px * 3) + 2] = (byte) ( rgb         & 0xFF);
+                }
+            }
+            sb.append(StringUtils.toBase64(bytes));
+            sb.append("\007");
+        }
+
+        if (saveInCache) {
+            // This row is OK to save into the cache.
+            jexerCache.put(cells, sb.toString());
+        }
+
+        return (gotoXY(x, y) + sb.toString());
+    }
+
+    /**
+     * Get the Jexer images support flag.
+     *
+     * @return true if this terminal is emitting Jexer images
+     */
+    public boolean hasJexerImages() {
+        return (jexerImageOption != JexerImageOption.DISABLED);
+    }
+
+    // ------------------------------------------------------------------------
+    // End Jexer image output support -----------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Setup system colors to match DOS color palette.
+     */
+    private void setDOSColors() {
+        MYBLACK         = new java.awt.Color(0x00, 0x00, 0x00);
+        MYRED           = new java.awt.Color(0xa8, 0x00, 0x00);
+        MYGREEN         = new java.awt.Color(0x00, 0xa8, 0x00);
+        MYYELLOW        = new java.awt.Color(0xa8, 0x54, 0x00);
+        MYBLUE          = new java.awt.Color(0x00, 0x00, 0xa8);
+        MYMAGENTA       = new java.awt.Color(0xa8, 0x00, 0xa8);
+        MYCYAN          = new java.awt.Color(0x00, 0xa8, 0xa8);
+        MYWHITE         = new java.awt.Color(0xa8, 0xa8, 0xa8);
+        MYBOLD_BLACK    = new java.awt.Color(0x54, 0x54, 0x54);
+        MYBOLD_RED      = new java.awt.Color(0xfc, 0x54, 0x54);
+        MYBOLD_GREEN    = new java.awt.Color(0x54, 0xfc, 0x54);
+        MYBOLD_YELLOW   = new java.awt.Color(0xfc, 0xfc, 0x54);
+        MYBOLD_BLUE     = new java.awt.Color(0x54, 0x54, 0xfc);
+        MYBOLD_MAGENTA  = new java.awt.Color(0xfc, 0x54, 0xfc);
+        MYBOLD_CYAN     = new java.awt.Color(0x54, 0xfc, 0xfc);
+        MYBOLD_WHITE    = new java.awt.Color(0xfc, 0xfc, 0xfc);
+    }
+
+    /**
+     * Setup ECMA48 colors to match those provided in system properties.
+     */
+    private void setCustomSystemColors() {
+        setDOSColors();
+
+        MYBLACK   = getCustomColor("jexer.ECMA48.color0", MYBLACK);
+        MYRED     = getCustomColor("jexer.ECMA48.color1", MYRED);
+        MYGREEN   = getCustomColor("jexer.ECMA48.color2", MYGREEN);
+        MYYELLOW  = getCustomColor("jexer.ECMA48.color3", MYYELLOW);
+        MYBLUE    = getCustomColor("jexer.ECMA48.color4", MYBLUE);
+        MYMAGENTA = getCustomColor("jexer.ECMA48.color5", MYMAGENTA);
+        MYCYAN    = getCustomColor("jexer.ECMA48.color6", MYCYAN);
+        MYWHITE   = getCustomColor("jexer.ECMA48.color7", MYWHITE);
+        MYBOLD_BLACK   = getCustomColor("jexer.ECMA48.color8", MYBOLD_BLACK);
+        MYBOLD_RED     = getCustomColor("jexer.ECMA48.color9", MYBOLD_RED);
+        MYBOLD_GREEN   = getCustomColor("jexer.ECMA48.color10", MYBOLD_GREEN);
+        MYBOLD_YELLOW  = getCustomColor("jexer.ECMA48.color11", MYBOLD_YELLOW);
+        MYBOLD_BLUE    = getCustomColor("jexer.ECMA48.color12", MYBOLD_BLUE);
+        MYBOLD_MAGENTA = getCustomColor("jexer.ECMA48.color13", MYBOLD_MAGENTA);
+        MYBOLD_CYAN    = getCustomColor("jexer.ECMA48.color14", MYBOLD_CYAN);
+        MYBOLD_WHITE   = getCustomColor("jexer.ECMA48.color15", MYBOLD_WHITE);
+    }
+
+    /**
+     * Setup one system color to match the RGB value provided in system
+     * properties.
+     *
+     * @param key the system property key
+     * @param defaultColor the default color to return if key is not set, or
+     * incorrect
+     * @return a color from the RGB string, or defaultColor
+     */
+    private java.awt.Color getCustomColor(final String key,
+        final java.awt.Color defaultColor) {
+
+        String rgb = System.getProperty(key);
+        if (rgb == null) {
+            return defaultColor;
+        }
+        if (rgb.startsWith("#")) {
+            rgb = rgb.substring(1);
+        }
+        int rgbInt = 0;
+        try {
+            rgbInt = Integer.parseInt(rgb, 16);
+        } catch (NumberFormatException e) {
+            return defaultColor;
+        }
+        java.awt.Color color = new java.awt.Color((rgbInt & 0xFF0000) >>> 16,
+            (rgbInt & 0x00FF00) >>> 8,
+            (rgbInt & 0x0000FF));
+
+        return color;
+    }
+
+    /**
+     * Create a T.416 RGB parameter sequence for a custom system color.
+     *
+     * @param color one of the MYBLACK, MYBOLD_BLUE, etc. colors
+     * @return the color portion of the string to emit to an ANSI /
+     * ECMA-style terminal
+     */
+    private String systemColorRGB(final java.awt.Color color) {
+        return String.format("%d;%d;%d", color.getRed(), color.getGreen(),
+            color.getBlue());
+    }
+
+    /**
+     * Create a SGR parameter sequence for a single color change.
+     *
+     * @param bold if true, set bold
+     * @param color one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param foreground if true, this is a foreground color
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[42m"
+     */
+    private String color(final boolean bold, final Color color,
+        final boolean foreground) {
+        return color(color, foreground, true) +
+                rgbColor(bold, color, foreground);
+    }
+
+    /**
+     * Create a T.416 RGB parameter sequence for a single color change.
+     *
+     * @param colorRGB a 24-bit RGB value for foreground color
+     * @param foreground if true, this is a foreground color
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[42m"
+     */
+    private String colorRGB(final int colorRGB, final boolean foreground) {
+
+        int colorRed     = (colorRGB >>> 16) & 0xFF;
+        int colorGreen   = (colorRGB >>>  8) & 0xFF;
+        int colorBlue    =  colorRGB         & 0xFF;
+
+        StringBuilder sb = new StringBuilder();
+        if (foreground) {
+            sb.append("\033[38;2;");
+        } else {
+            sb.append("\033[48;2;");
+        }
+        sb.append(String.format("%d;%d;%dm", colorRed, colorGreen, colorBlue));
+        return sb.toString();
+    }
+
+    /**
+     * Create a T.416 RGB parameter sequence for both foreground and
+     * background color change.
+     *
+     * @param foreColorRGB a 24-bit RGB value for foreground color
+     * @param backColorRGB a 24-bit RGB value for foreground color
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[42m"
+     */
+    private String colorRGB(final int foreColorRGB, final int backColorRGB) {
+        int foreColorRed     = (foreColorRGB >>> 16) & 0xFF;
+        int foreColorGreen   = (foreColorRGB >>>  8) & 0xFF;
+        int foreColorBlue    =  foreColorRGB         & 0xFF;
+        int backColorRed     = (backColorRGB >>> 16) & 0xFF;
+        int backColorGreen   = (backColorRGB >>>  8) & 0xFF;
+        int backColorBlue    =  backColorRGB         & 0xFF;
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(String.format("\033[38;2;%d;%d;%dm",
+                foreColorRed, foreColorGreen, foreColorBlue));
+        sb.append(String.format("\033[48;2;%d;%d;%dm",
+                backColorRed, backColorGreen, backColorBlue));
+        return sb.toString();
+    }
+
+    /**
+     * Create a T.416 RGB parameter sequence for a single color change.
+     *
+     * @param bold if true, set bold
+     * @param color one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param foreground if true, this is a foreground color
+     * @return the string to emit to an xterm terminal with RGB support,
+     * e.g. "\033[38;2;RR;GG;BBm"
+     */
+    private String rgbColor(final boolean bold, final Color color,
+        final boolean foreground) {
+        if (doRgbColor == false) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder("\033[");
+        if (bold) {
+            // Bold implies foreground only
+            sb.append("38;2;");
+            if (color.equals(Color.BLACK)) {
+                sb.append(systemColorRGB(MYBOLD_BLACK));
+            } else if (color.equals(Color.RED)) {
+                sb.append(systemColorRGB(MYBOLD_RED));
+            } else if (color.equals(Color.GREEN)) {
+                sb.append(systemColorRGB(MYBOLD_GREEN));
+            } else if (color.equals(Color.YELLOW)) {
+                sb.append(systemColorRGB(MYBOLD_YELLOW));
+            } else if (color.equals(Color.BLUE)) {
+                sb.append(systemColorRGB(MYBOLD_BLUE));
+            } else if (color.equals(Color.MAGENTA)) {
+                sb.append(systemColorRGB(MYBOLD_MAGENTA));
+            } else if (color.equals(Color.CYAN)) {
+                sb.append(systemColorRGB(MYBOLD_CYAN));
+            } else if (color.equals(Color.WHITE)) {
+                sb.append(systemColorRGB(MYBOLD_WHITE));
+            }
+        } else {
+            if (foreground) {
+                sb.append("38;2;");
+            } else {
+                sb.append("48;2;");
+            }
+            if (color.equals(Color.BLACK)) {
+                sb.append(systemColorRGB(MYBLACK));
+            } else if (color.equals(Color.RED)) {
+                sb.append(systemColorRGB(MYRED));
+            } else if (color.equals(Color.GREEN)) {
+                sb.append(systemColorRGB(MYGREEN));
+            } else if (color.equals(Color.YELLOW)) {
+                sb.append(systemColorRGB(MYYELLOW));
+            } else if (color.equals(Color.BLUE)) {
+                sb.append(systemColorRGB(MYBLUE));
+            } else if (color.equals(Color.MAGENTA)) {
+                sb.append(systemColorRGB(MYMAGENTA));
+            } else if (color.equals(Color.CYAN)) {
+                sb.append(systemColorRGB(MYCYAN));
+            } else if (color.equals(Color.WHITE)) {
+                sb.append(systemColorRGB(MYWHITE));
+            }
+        }
+        sb.append("m");
+        return sb.toString();
+    }
+
+    /**
+     * Create a T.416 RGB parameter sequence for both foreground and
+     * background color change.
+     *
+     * @param bold if true, set bold
+     * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @return the string to emit to an xterm terminal with RGB support,
+     * e.g. "\033[38;2;RR;GG;BB;48;2;RR;GG;BBm"
+     */
+    private String rgbColor(final boolean bold, final Color foreColor,
+        final Color backColor) {
+        if (doRgbColor == false) {
+            return "";
+        }
+
+        return rgbColor(bold, foreColor, true) +
+                rgbColor(false, backColor, false);
+    }
+
+    /**
+     * Create a SGR parameter sequence for a single color change.
+     *
+     * @param color one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param foreground if true, this is a foreground color
+     * @param header if true, make the full header, otherwise just emit the
+     * color parameter e.g. "42;"
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[42m"
+     */
+    private String color(final Color color, final boolean foreground,
+        final boolean header) {
+
+        int ecmaColor = color.getValue();
+
+        // Convert Color.* values to SGR numerics
+        if (foreground) {
+            ecmaColor += 30;
+        } else {
+            ecmaColor += 40;
+        }
+
+        if (header) {
+            return String.format("\033[%dm", ecmaColor);
+        } else {
+            return String.format("%d;", ecmaColor);
+        }
+    }
+
+    /**
+     * Create a SGR parameter sequence for both foreground and background
+     * color change.
+     *
+     * @param bold if true, set bold
+     * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[31;42m"
+     */
+    private String color(final boolean bold, final Color foreColor,
+        final Color backColor) {
+        return color(foreColor, backColor, true) +
+                rgbColor(bold, foreColor, backColor);
+    }
+
+    /**
+     * Create a SGR parameter sequence for both foreground and
+     * background color change.
+     *
+     * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param header if true, make the full header, otherwise just emit the
+     * color parameter e.g. "31;42;"
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[31;42m"
+     */
+    private String color(final Color foreColor, final Color backColor,
+        final boolean header) {
+
+        int ecmaForeColor = foreColor.getValue();
+        int ecmaBackColor = backColor.getValue();
+
+        // Convert Color.* values to SGR numerics
+        ecmaBackColor += 40;
+        ecmaForeColor += 30;
+
+        if (header) {
+            return String.format("\033[%d;%dm", ecmaForeColor, ecmaBackColor);
+        } else {
+            return String.format("%d;%d;", ecmaForeColor, ecmaBackColor);
+        }
+    }
+
+    /**
+     * Create a SGR parameter sequence for foreground, background, and
+     * several attributes.  This sequence first resets all attributes to
+     * default, then sets attributes as per the parameters.
+     *
+     * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+     * @param bold if true, set bold
+     * @param reverse if true, set reverse
+     * @param blink if true, set blink
+     * @param underline if true, set underline
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0;1;31;42m"
+     */
+    private String color(final Color foreColor, final Color backColor,
+        final boolean bold, final boolean reverse, final boolean blink,
+        final boolean underline) {
+
+        int ecmaForeColor = foreColor.getValue();
+        int ecmaBackColor = backColor.getValue();
+
+        // Convert Color.* values to SGR numerics
+        ecmaBackColor += 40;
+        ecmaForeColor += 30;
+
+        StringBuilder sb = new StringBuilder();
+        if        (  bold &&  reverse &&  blink && !underline ) {
+            sb.append("\033[0;1;7;5;");
+        } else if (  bold &&  reverse && !blink && !underline ) {
+            sb.append("\033[0;1;7;");
+        } else if ( !bold &&  reverse &&  blink && !underline ) {
+            sb.append("\033[0;7;5;");
+        } else if (  bold && !reverse &&  blink && !underline ) {
+            sb.append("\033[0;1;5;");
+        } else if (  bold && !reverse && !blink && !underline ) {
+            sb.append("\033[0;1;");
+        } else if ( !bold &&  reverse && !blink && !underline ) {
+            sb.append("\033[0;7;");
+        } else if ( !bold && !reverse &&  blink && !underline) {
+            sb.append("\033[0;5;");
+        } else if (  bold &&  reverse &&  blink &&  underline ) {
+            sb.append("\033[0;1;7;5;4;");
+        } else if (  bold &&  reverse && !blink &&  underline ) {
+            sb.append("\033[0;1;7;4;");
+        } else if ( !bold &&  reverse &&  blink &&  underline ) {
+            sb.append("\033[0;7;5;4;");
+        } else if (  bold && !reverse &&  blink &&  underline ) {
+            sb.append("\033[0;1;5;4;");
+        } else if (  bold && !reverse && !blink &&  underline ) {
+            sb.append("\033[0;1;4;");
+        } else if ( !bold &&  reverse && !blink &&  underline ) {
+            sb.append("\033[0;7;4;");
+        } else if ( !bold && !reverse &&  blink &&  underline) {
+            sb.append("\033[0;5;4;");
+        } else if ( !bold && !reverse && !blink &&  underline) {
+            sb.append("\033[0;4;");
+        } else {
+            assert (!bold && !reverse && !blink && !underline);
+            sb.append("\033[0;");
+        }
+        sb.append(String.format("%d;%dm", ecmaForeColor, ecmaBackColor));
+        sb.append(rgbColor(bold, foreColor, backColor));
+        return sb.toString();
+    }
+
+    /**
+     * Create a SGR parameter sequence for foreground, background, and
+     * several attributes.  This sequence first resets all attributes to
+     * default, then sets attributes as per the parameters.
+     *
+     * @param foreColorRGB a 24-bit RGB value for foreground color
+     * @param backColorRGB a 24-bit RGB value for foreground color
+     * @param bold if true, set bold
+     * @param reverse if true, set reverse
+     * @param blink if true, set blink
+     * @param underline if true, set underline
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0;1;31;42m"
+     */
+    private String colorRGB(final int foreColorRGB, final int backColorRGB,
+        final boolean bold, final boolean reverse, final boolean blink,
+        final boolean underline) {
+
+        int foreColorRed     = (foreColorRGB >>> 16) & 0xFF;
+        int foreColorGreen   = (foreColorRGB >>>  8) & 0xFF;
+        int foreColorBlue    =  foreColorRGB         & 0xFF;
+        int backColorRed     = (backColorRGB >>> 16) & 0xFF;
+        int backColorGreen   = (backColorRGB >>>  8) & 0xFF;
+        int backColorBlue    =  backColorRGB         & 0xFF;
+
+        StringBuilder sb = new StringBuilder();
+        if        (  bold &&  reverse &&  blink && !underline ) {
+            sb.append("\033[0;1;7;5;");
+        } else if (  bold &&  reverse && !blink && !underline ) {
+            sb.append("\033[0;1;7;");
+        } else if ( !bold &&  reverse &&  blink && !underline ) {
+            sb.append("\033[0;7;5;");
+        } else if (  bold && !reverse &&  blink && !underline ) {
+            sb.append("\033[0;1;5;");
+        } else if (  bold && !reverse && !blink && !underline ) {
+            sb.append("\033[0;1;");
+        } else if ( !bold &&  reverse && !blink && !underline ) {
+            sb.append("\033[0;7;");
+        } else if ( !bold && !reverse &&  blink && !underline) {
+            sb.append("\033[0;5;");
+        } else if (  bold &&  reverse &&  blink &&  underline ) {
+            sb.append("\033[0;1;7;5;4;");
+        } else if (  bold &&  reverse && !blink &&  underline ) {
+            sb.append("\033[0;1;7;4;");
+        } else if ( !bold &&  reverse &&  blink &&  underline ) {
+            sb.append("\033[0;7;5;4;");
+        } else if (  bold && !reverse &&  blink &&  underline ) {
+            sb.append("\033[0;1;5;4;");
+        } else if (  bold && !reverse && !blink &&  underline ) {
+            sb.append("\033[0;1;4;");
+        } else if ( !bold &&  reverse && !blink &&  underline ) {
+            sb.append("\033[0;7;4;");
+        } else if ( !bold && !reverse &&  blink &&  underline) {
+            sb.append("\033[0;5;4;");
+        } else if ( !bold && !reverse && !blink &&  underline) {
+            sb.append("\033[0;4;");
+        } else {
+            assert (!bold && !reverse && !blink && !underline);
+            sb.append("\033[0;");
+        }
+
+        sb.append("m\033[38;2;");
+        sb.append(String.format("%d;%d;%d", foreColorRed, foreColorGreen,
+                foreColorBlue));
+        sb.append("m\033[48;2;");
+        sb.append(String.format("%d;%d;%d", backColorRed, backColorGreen,
+                backColorBlue));
+        sb.append("m");
+        return sb.toString();
+    }
+
+    /**
+     * Create a SGR parameter sequence to reset to VT100 defaults.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0m"
+     */
+    private String normal() {
+        return normal(true) + rgbColor(false, Color.WHITE, Color.BLACK);
+    }
+
+    /**
+     * Create a SGR parameter sequence to reset to ECMA-48 default
+     * foreground/background.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0m"
+     */
+    private String defaultColor() {
+        /*
+         * VT100 normal.
+         * Normal (neither bold nor faint).
+         * Not italicized.
+         * Not underlined.
+         * Steady (not blinking).
+         * Positive (not inverse).
+         * Visible (not hidden).
+         * Not crossed-out.
+         * Default foreground color.
+         * Default background color.
+         */
+        return "\033[0;22;23;24;25;27;28;29;39;49m";
+    }
+
+    /**
+     * Create a SGR parameter sequence to reset to defaults.
+     *
+     * @param header if true, make the full header, otherwise just emit the
+     * bare parameter e.g. "0;"
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0m"
+     */
+    private String normal(final boolean header) {
+        if (header) {
+            return "\033[0;37;40m";
+        }
+        return "0;37;40";
+    }
+
+    /**
+     * Create a SGR parameter sequence for enabling the visible cursor.
+     *
+     * @param on if true, turn on cursor
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String cursor(final boolean on) {
+        if (on && !cursorOn) {
+            cursorOn = true;
+            return "\033[?25h";
+        }
+        if (!on && cursorOn) {
+            cursorOn = false;
+            return "\033[?25l";
+        }
+        return "";
+    }
+
+    /**
+     * Clear the entire screen.  Because some terminals use back-color-erase,
+     * set the color to white-on-black beforehand.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String clearAll() {
+        return "\033[0;37;40m\033[2J";
+    }
+
+    /**
+     * Clear the line from the cursor (inclusive) to the end of the screen.
+     * Because some terminals use back-color-erase, set the color to
+     * white-on-black beforehand.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String clearRemainingLine() {
+        return "\033[0;37;40m\033[K";
+    }
+
+    /**
+     * Move the cursor to (x, y).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String gotoXY(final int x, final int y) {
+        return String.format("\033[%d;%dH", y + 1, x + 1);
+    }
+
+    /**
+     * Tell (u)xterm that we want to receive mouse events based on "Any event
+     * tracking", UTF-8 coordinates, and then SGR coordinates.  Ideally we
+     * will end up with SGR coordinates with UTF-8 coordinates as a fallback.
+     * See
+     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     *
+     * Note that this also sets the alternate/primary screen buffer.
+     *
+     * Finally, also emit a Privacy Message sequence that Jexer recognizes to
+     * mean "hide the mouse pointer."  We have to use our own sequence to do
+     * this because there is no standard in xterm for unilaterally hiding the
+     * pointer all the time (regardless of typing).
+     *
+     * @param on If true, enable mouse report and use the alternate screen
+     * buffer.  If false disable mouse reporting and use the primary screen
+     * buffer.
+     * @return the string to emit to xterm
+     */
+    private String mouse(final boolean on) {
+        if (on) {
+            return "\033[?1002;1003;1005;1006h\033[?1049h\033^hideMousePointer\033\\";
+        }
+        return "\033[?1002;1003;1006;1005l\033[?1049l\033^showMousePointer\033\\";
+    }
+
+}
diff --git a/src/jexer/backend/GenericBackend.java b/src/jexer/backend/GenericBackend.java
new file mode 100644 (file)
index 0000000..ede3c0b
--- /dev/null
@@ -0,0 +1,171 @@
+/*
+ * 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.backend;
+
+import java.util.List;
+
+import jexer.event.TInputEvent;
+import jexer.event.TCommandEvent;
+import static jexer.TCommand.*;
+
+/**
+ * This abstract class provides a screen, keyboard, and mouse to
+ * TApplication.  It also exposes session information as gleaned from lower
+ * levels of the communication stack.
+ */
+public abstract class GenericBackend implements Backend {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The session information.
+     */
+    protected SessionInfo sessionInfo;
+
+    /**
+     * The screen to draw on.
+     */
+    protected Screen screen;
+
+    /**
+     * Input events are processed by this Terminal.
+     */
+    protected TerminalReader terminal;
+
+    /**
+     * By default, GenericBackend adds a cmAbort after it sees
+     * cmBackendDisconnect, so that TApplication will exit when the user
+     * closes the Swing window or disconnects the ECMA48 streams.  But
+     * MultiBackend wraps multiple Backends, and needs to decide when to send
+     * cmAbort differently.  Setting this to false is how it manages that.
+     * Note package private access.
+     */
+    boolean abortOnDisconnect = true;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Backend ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Getter for sessionInfo.
+     *
+     * @return the SessionInfo
+     */
+    public final SessionInfo getSessionInfo() {
+        return sessionInfo;
+    }
+
+    /**
+     * Getter for screen.
+     *
+     * @return the Screen
+     */
+    public final Screen getScreen() {
+        return screen;
+    }
+
+    /**
+     * Sync the logical screen to the physical device.
+     */
+    public void flushScreen() {
+        screen.flushPhysical();
+    }
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the application
+     */
+    public boolean hasEvents() {
+        return terminal.hasEvents();
+    }
+
+    /**
+     * Get keyboard, mouse, and screen resize events.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(final List<TInputEvent> queue) {
+        if (terminal.hasEvents()) {
+            terminal.getEvents(queue);
+
+            // This default backend assumes a single user, and if that user
+            // becomes disconnected we should terminate the application.
+            if ((queue.size() > 0) && (abortOnDisconnect == true)) {
+                TInputEvent event = queue.get(queue.size() - 1);
+                if (event instanceof TCommandEvent) {
+                    TCommandEvent command = (TCommandEvent) event;
+                    if (command.equals(cmBackendDisconnect)) {
+                        queue.add(new TCommandEvent(cmAbort));
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Close the I/O, restore the console, etc.
+     */
+    public void shutdown() {
+        terminal.closeTerminal();
+    }
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title) {
+        screen.setTitle(title);
+    }
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener) {
+        terminal.setListener(listener);
+    }
+
+    /**
+     * Reload backend options from System properties.
+     */
+    public void reloadOptions() {
+        terminal.reloadOptions();
+    }
+
+}
diff --git a/src/jexer/backend/GlyphMaker.java b/src/jexer/backend/GlyphMaker.java
new file mode 100644 (file)
index 0000000..e5fcc52
--- /dev/null
@@ -0,0 +1,473 @@
+/*
+ * 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.backend;
+
+import java.awt.Font;
+import java.awt.FontFormatException;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.io.InputStream;
+import java.io.IOException;
+import java.util.HashMap;
+
+import jexer.bits.Cell;
+import jexer.bits.StringUtils;
+
+/**
+ * GlyphMakerFont creates glyphs as bitmaps from a font.
+ */
+class GlyphMakerFont {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, enable debug messages.
+     */
+    private static boolean DEBUG = false;
+
+    /**
+     * If true, we were successful at getting the font dimensions.
+     */
+    private boolean gotFontDimensions = false;
+
+    /**
+     * The currently selected font.
+     */
+    private Font font = null;
+
+    /**
+     * Width of a character cell in pixels.
+     */
+    private int textWidth = 1;
+
+    /**
+     * Height of a character cell in pixels.
+     */
+    private int textHeight = 1;
+
+    /**
+     * Width of a character cell in pixels, as reported by font.
+     */
+    private int fontTextWidth = 1;
+
+    /**
+     * Height of a character cell in pixels, as reported by font.
+     */
+    private int fontTextHeight = 1;
+
+    /**
+     * Descent of a character cell in pixels.
+     */
+    private int maxDescent = 0;
+
+    /**
+     * System-dependent Y adjustment for text in the character cell.
+     */
+    private int textAdjustY = 0;
+
+    /**
+     * System-dependent X adjustment for text in the character cell.
+     */
+    private int textAdjustX = 0;
+
+    /**
+     * System-dependent height adjustment for text in the character cell.
+     */
+    private int textAdjustHeight = 0;
+
+    /**
+     * System-dependent width adjustment for text in the character cell.
+     */
+    private int textAdjustWidth = 0;
+
+    /**
+     * A cache of previously-rendered glyphs for blinking text, when it is
+     * not visible.
+     */
+    private HashMap<Cell, BufferedImage> glyphCacheBlink;
+
+    /**
+     * A cache of previously-rendered glyphs for non-blinking, or
+     * blinking-and-visible, text.
+     */
+    private HashMap<Cell, BufferedImage> glyphCache;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param filename the resource filename of the font to use
+     * @param fontSize the size of font to use
+     */
+    public GlyphMakerFont(final String filename, final int fontSize) {
+
+        if (filename.length() == 0) {
+            // Fallback font
+            font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize - 2);
+            return;
+        }
+
+        Font fontRoot = null;
+        try {
+            ClassLoader loader = Thread.currentThread().getContextClassLoader();
+            InputStream in = loader.getResourceAsStream(filename);
+            fontRoot = Font.createFont(Font.TRUETYPE_FONT, in);
+            font = fontRoot.deriveFont(Font.PLAIN, fontSize - 2);
+        } catch (FontFormatException e) {
+            // Ideally we would report an error here, either via System.err
+            // or TExceptionDialog.  However, I do not want GlyphMaker to
+            // know about available backends, so we quietly fallback to
+            // whatever is available as MONO.
+            font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize - 2);
+        } catch (IOException e) {
+            // See comment above.
+            font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize - 2);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // GlyphMakerFont ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get a glyph image.
+     *
+     * @param cell the character to draw
+     * @param cellWidth the width of the text cell to draw into
+     * @param cellHeight the height of the text cell to draw into
+     * @return the glyph as an image
+     */
+    public BufferedImage getImage(final Cell cell, final int cellWidth,
+        final int cellHeight) {
+
+        return getImage(cell, cellWidth, cellHeight, true);
+    }
+
+    /**
+     * Get a glyph image.
+     *
+     * @param cell the character to draw
+     * @param cellWidth the width of the text cell to draw into
+     * @param cellHeight the height of the text cell to draw into
+     * @param blinkVisible if true, the cell is visible if it is blinking
+     * @return the glyph as an image
+     */
+    public BufferedImage getImage(final Cell cell, final int cellWidth,
+        final int cellHeight, final boolean blinkVisible) {
+
+        if (gotFontDimensions == false) {
+            // Lazy-load the text width/height and adjustments.
+            getFontDimensions();
+        }
+
+        if (DEBUG && !font.canDisplay(cell.getChar())) {
+            System.err.println("font " + font + " has no glyph for " +
+                String.format("0x%x", cell.getChar()));
+        }
+
+        BufferedImage image = null;
+        if (cell.isBlink() && !blinkVisible) {
+            image = glyphCacheBlink.get(cell);
+        } else {
+            image = glyphCache.get(cell);
+        }
+        if (image != null) {
+            return image;
+        }
+
+        // Generate glyph and draw it.
+        image = new BufferedImage(cellWidth, cellHeight,
+            BufferedImage.TYPE_INT_ARGB);
+        Graphics2D gr2 = image.createGraphics();
+        gr2.setFont(font);
+
+        Cell cellColor = new Cell(cell);
+
+        // Check for reverse
+        if (cell.isReverse()) {
+            cellColor.setForeColor(cell.getBackColor());
+            cellColor.setBackColor(cell.getForeColor());
+        }
+
+        // Draw the background rectangle, then the foreground character.
+        gr2.setColor(SwingTerminal.attrToBackgroundColor(cellColor));
+        gr2.fillRect(0, 0, cellWidth, cellHeight);
+
+        // Handle blink and underline
+        if (!cell.isBlink()
+            || (cell.isBlink() && blinkVisible)
+        ) {
+            gr2.setColor(SwingTerminal.attrToForegroundColor(cellColor));
+            char [] chars = Character.toChars(cell.getChar());
+            gr2.drawChars(chars, 0, chars.length, textAdjustX,
+                cellHeight - maxDescent + textAdjustY);
+
+            if (cell.isUnderline()) {
+                gr2.fillRect(0, cellHeight - 2, cellWidth, 2);
+            }
+        }
+        gr2.dispose();
+
+        // We need a new key that will not be mutated by invertCell().
+        Cell key = new Cell(cell);
+        if (cell.isBlink() && !blinkVisible) {
+            glyphCacheBlink.put(key, image);
+        } else {
+            glyphCache.put(key, image);
+        }
+
+        /*
+        System.err.println("cellWidth " + cellWidth +
+            " cellHeight " + cellHeight + " image " + image);
+         */
+
+        return image;
+    }
+
+    /**
+     * Figure out my font dimensions.
+     */
+    private void getFontDimensions() {
+        glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+        glyphCache = new HashMap<Cell, BufferedImage>();
+
+        BufferedImage image = new BufferedImage(font.getSize() * 2,
+            font.getSize() * 2, BufferedImage.TYPE_INT_ARGB);
+        Graphics2D gr = image.createGraphics();
+        gr.setFont(font);
+        FontMetrics fm = gr.getFontMetrics();
+        maxDescent = fm.getMaxDescent();
+        Rectangle2D bounds = fm.getMaxCharBounds(gr);
+        int leading = fm.getLeading();
+        fontTextWidth = (int)Math.round(bounds.getWidth());
+        // fontTextHeight = (int)Math.round(bounds.getHeight()) - maxDescent;
+
+        // This produces the same number, but works better for ugly
+        // monospace.
+        fontTextHeight = fm.getMaxAscent() + maxDescent - leading;
+        gr.dispose();
+
+        textHeight = fontTextHeight + textAdjustHeight;
+        textWidth = fontTextWidth + textAdjustWidth;
+        /*
+        System.err.println("font " + font);
+        System.err.println("fontTextWidth " + fontTextWidth);
+        System.err.println("fontTextHeight " + fontTextHeight);
+        System.err.println("textWidth " + textWidth);
+        System.err.println("textHeight " + textHeight);
+         */
+
+        gotFontDimensions = true;
+    }
+
+    /**
+     * Checks if this maker's Font has a glyph for the specified character.
+     *
+     * @param codePoint the character (Unicode code point) for which a glyph
+     * is needed.
+     * @return true if this Font has a glyph for the character; false
+     * otherwise.
+     */
+    public boolean canDisplay(final int codePoint) {
+        return font.canDisplay(codePoint);
+    }
+}
+
+/**
+ * GlyphMaker presents unified interface to all of its supported fonts to
+ * clients.
+ */
+public class GlyphMaker {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The mono font resource filename (terminus).
+     */
+    private static final String MONO = "terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf";
+
+    /**
+     * The CJK font resource filename.
+     */
+    private static final String cjkFontFilename = "NotoSansMonoCJKtc-Regular.otf";
+
+    /**
+     * The emoji font resource filename.
+     */
+    private static final String emojiFontFilename = "OpenSansEmoji.ttf";
+
+    /**
+     * The fallback font resource filename.
+     */
+    private static final String fallbackFontFilename = "";
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, enable debug messages.
+     */
+    private static boolean DEBUG = false;
+
+    /**
+     * Cache of font bundles by size.
+     */
+    private static HashMap<Integer, GlyphMaker> makers = new HashMap<Integer, GlyphMaker>();
+
+    /**
+     * The instance that has the mono (default) font.
+     */
+    private GlyphMakerFont makerMono;
+
+    /**
+     * The instance that has the CJK font.
+     */
+    private GlyphMakerFont makerCjk;
+
+    /**
+     * The instance that has the emoji font.
+     */
+    private GlyphMakerFont makerEmoji;
+
+    /**
+     * The instance that has the fallback font.
+     */
+    private GlyphMakerFont makerFallback;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create an instance with references to the necessary fonts.
+     *
+     * @param fontSize the size of these fonts in pixels
+     */
+    private GlyphMaker(final int fontSize) {
+        makerMono = new GlyphMakerFont(MONO, fontSize);
+
+        String fontFilename = null;
+        fontFilename = System.getProperty("jexer.cjkFont.filename",
+            cjkFontFilename);
+        makerCjk = new GlyphMakerFont(fontFilename, fontSize);
+        fontFilename = System.getProperty("jexer.emojiFont.filename",
+            emojiFontFilename);
+        makerEmoji = new GlyphMakerFont(fontFilename, fontSize);
+        fontFilename = System.getProperty("jexer.fallbackFont.filename",
+            fallbackFontFilename);
+        makerFallback = new GlyphMakerFont(fontFilename, fontSize);
+    }
+
+    // ------------------------------------------------------------------------
+    // GlyphMaker -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Obtain the GlyphMaker instance for a particular font size.
+     *
+     * @param fontSize the size of these fonts in pixels
+     * @return the instance
+     */
+    public static GlyphMaker getInstance(final int fontSize) {
+        synchronized (GlyphMaker.class) {
+            GlyphMaker maker = makers.get(fontSize);
+            if (maker == null) {
+                maker = new GlyphMaker(fontSize);
+                makers.put(fontSize, maker);
+            }
+            return maker;
+        }
+    }
+
+    /**
+     * Get a glyph image.
+     *
+     * @param cell the character to draw
+     * @param cellWidth the width of the text cell to draw into
+     * @param cellHeight the height of the text cell to draw into
+     * @return the glyph as an image
+     */
+    public BufferedImage getImage(final Cell cell, final int cellWidth,
+        final int cellHeight) {
+
+        return getImage(cell, cellWidth, cellHeight, true);
+    }
+
+    /**
+     * Get a glyph image.
+     *
+     * @param cell the character to draw
+     * @param cellWidth the width of the text cell to draw into
+     * @param cellHeight the height of the text cell to draw into
+     * @param blinkVisible if true, the cell is visible if it is blinking
+     * @return the glyph as an image
+     */
+    public BufferedImage getImage(final Cell cell, final int cellWidth,
+        final int cellHeight, final boolean blinkVisible) {
+
+        int ch = cell.getChar();
+        if (StringUtils.isCjk(ch)) {
+            if (makerCjk.canDisplay(ch)) {
+                return makerCjk.getImage(cell, cellWidth, cellHeight,
+                    blinkVisible);
+            }
+        }
+        if (StringUtils.isEmoji(ch)) {
+            if (makerEmoji.canDisplay(ch)) {
+                // System.err.println("emoji: " + String.format("0x%x", ch));
+                return makerEmoji.getImage(cell, cellWidth, cellHeight,
+                    blinkVisible);
+            }
+        }
+
+        // When all else fails, use the default.
+        if (makerMono.canDisplay(ch)) {
+            return makerMono.getImage(cell, cellWidth, cellHeight,
+                blinkVisible);
+        }
+
+        return makerFallback.getImage(cell, cellWidth, cellHeight,
+            blinkVisible);
+    }
+
+}
diff --git a/src/jexer/backend/HeadlessBackend.java b/src/jexer/backend/HeadlessBackend.java
new file mode 100644 (file)
index 0000000..19fb589
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * 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.backend;
+
+import java.util.List;
+
+import jexer.event.TInputEvent;
+
+/**
+ * HeadlessBackend
+ */
+public class HeadlessBackend extends LogicalScreen implements Backend {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The session information.
+     */
+    private SessionInfo sessionInfo;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     */
+    public HeadlessBackend() {
+        sessionInfo = new TSessionInfo(width, height);
+    }
+
+    // ------------------------------------------------------------------------
+    // Backend ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Getter for sessionInfo.
+     *
+     * @return the SessionInfo
+     */
+    public final SessionInfo getSessionInfo() {
+        return sessionInfo;
+    }
+
+    /**
+     * Get a Screen, which displays the text cells to the user.
+     *
+     * @return the Screen
+     */
+    public Screen getScreen() {
+        return this;
+    }
+
+    /**
+     * Subclasses must provide an implementation that syncs the logical
+     * screen to the physical device.
+     */
+    public void flushScreen() {
+        // NOP
+    }
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the application
+     */
+    public boolean hasEvents() {
+        return false;
+    }
+
+    /**
+     * Subclasses must provide an implementation to get keyboard, mouse, and
+     * screen resize events.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(List<TInputEvent> queue) {
+        // NOP
+    }
+
+    /**
+     * Subclasses must provide an implementation that closes sockets,
+     * restores console, etc.
+     */
+    public void shutdown() {
+        // NOP
+    }
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener) {
+        // NOP
+    }
+
+    /**
+     * Reload backend options from System properties.
+     */
+    public void reloadOptions() {
+        // NOP
+    }
+
+}
diff --git a/src/jexer/backend/LogicalScreen.java b/src/jexer/backend/LogicalScreen.java
new file mode 100644 (file)
index 0000000..22b7e95
--- /dev/null
@@ -0,0 +1,1226 @@
+/*
+ * 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.backend;
+
+import java.awt.image.BufferedImage;
+
+import jexer.backend.GlyphMaker;
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.Clipboard;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+
+/**
+ * A logical screen composed of a 2D array of Cells.
+ */
+public class LogicalScreen implements Screen {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Width of the visible window.
+     */
+    protected int width;
+
+    /**
+     * Height of the visible window.
+     */
+    protected int height;
+
+    /**
+     * Drawing offset for x.
+     */
+    private int offsetX;
+
+    /**
+     * Drawing offset for y.
+     */
+    private int offsetY;
+
+    /**
+     * Ignore anything drawn right of clipRight.
+     */
+    private int clipRight;
+
+    /**
+     * Ignore anything drawn below clipBottom.
+     */
+    private int clipBottom;
+
+    /**
+     * Ignore anything drawn left of clipLeft.
+     */
+    private int clipLeft;
+
+    /**
+     * Ignore anything drawn above clipTop.
+     */
+    private int clipTop;
+
+    /**
+     * The physical screen last sent out on flush().
+     */
+    protected Cell [][] physical;
+
+    /**
+     * The logical screen being rendered to.
+     */
+    protected Cell [][] logical;
+
+    /**
+     * Set if the user explicitly wants to redraw everything starting with a
+     * ECMATerminal.clearAll().
+     */
+    protected boolean reallyCleared;
+
+    /**
+     * If true, the cursor is visible and should be placed onscreen at
+     * (cursorX, cursorY) during a call to flushPhysical().
+     */
+    protected boolean cursorVisible;
+
+    /**
+     * Cursor X position if visible.
+     */
+    protected int cursorX;
+
+    /**
+     * Cursor Y position if visible.
+     */
+    protected int cursorY;
+
+    /**
+     * The last used height of a character cell in pixels, only used for
+     * full-width chars.
+     */
+    private int lastTextHeight = -1;
+
+    /**
+     * The glyph drawer for full-width chars.
+     */
+    private GlyphMaker glyphMaker = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  Sets everything to not-bold, white-on-black.
+     */
+    protected LogicalScreen() {
+        offsetX  = 0;
+        offsetY  = 0;
+        width    = 80;
+        height   = 24;
+        logical  = null;
+        physical = null;
+        reallocate(width, height);
+    }
+
+    // ------------------------------------------------------------------------
+    // Screen -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the width of a character cell in pixels.
+     *
+     * @return the width in pixels of a character cell
+     */
+    public int getTextWidth() {
+        // Default width is 16 pixels.
+        return 16;
+    }
+
+    /**
+     * Get the height of a character cell in pixels.
+     *
+     * @return the height in pixels of a character cell
+     */
+    public int getTextHeight() {
+        // Default height is 20 pixels.
+        return 20;
+    }
+
+    /**
+     * Set drawing offset for x.
+     *
+     * @param offsetX new drawing offset
+     */
+    public final void setOffsetX(final int offsetX) {
+        this.offsetX = offsetX;
+    }
+
+    /**
+     * Set drawing offset for y.
+     *
+     * @param offsetY new drawing offset
+     */
+    public final void setOffsetY(final int offsetY) {
+        this.offsetY = offsetY;
+    }
+
+    /**
+     * Get right drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public final int getClipRight() {
+        return clipRight;
+    }
+
+    /**
+     * Set right drawing clipping boundary.
+     *
+     * @param clipRight new boundary
+     */
+    public final void setClipRight(final int clipRight) {
+        this.clipRight = clipRight;
+    }
+
+    /**
+     * Get bottom drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public final int getClipBottom() {
+        return clipBottom;
+    }
+
+    /**
+     * Set bottom drawing clipping boundary.
+     *
+     * @param clipBottom new boundary
+     */
+    public final void setClipBottom(final int clipBottom) {
+        this.clipBottom = clipBottom;
+    }
+
+    /**
+     * Get left drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public final int getClipLeft() {
+        return clipLeft;
+    }
+
+    /**
+     * Set left drawing clipping boundary.
+     *
+     * @param clipLeft new boundary
+     */
+    public final void setClipLeft(final int clipLeft) {
+        this.clipLeft = clipLeft;
+    }
+
+    /**
+     * Get top drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public final int getClipTop() {
+        return clipTop;
+    }
+
+    /**
+     * Set top drawing clipping boundary.
+     *
+     * @param clipTop new boundary
+     */
+    public final void setClipTop(final int clipTop) {
+        this.clipTop = clipTop;
+    }
+
+    /**
+     * Get dirty flag.
+     *
+     * @return if true, the logical screen is not in sync with the physical
+     * screen
+     */
+    public final boolean isDirty() {
+        for (int x = 0; x < width; x++) {
+            for (int y = 0; y < height; y++) {
+                if (!logical[x][y].equals(physical[x][y])) {
+                    return true;
+                }
+                if (logical[x][y].isBlink()) {
+                    // Blinking screens are always dirty.  There is
+                    // opportunity for a Netscape blink tag joke here...
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Get the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return attributes at (x, y)
+     */
+    public final CellAttributes getAttrXY(final int x, final int y) {
+        CellAttributes attr = new CellAttributes();
+        if ((x >= 0) && (x < width) && (y >= 0) && (y < height)) {
+            attr.setTo(logical[x][y]);
+        }
+        return attr;
+    }
+
+    /**
+     * Get the cell at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return the character + attributes
+     */
+    public Cell getCharXY(final int x, final int y) {
+        Cell cell = new Cell();
+        if ((x >= 0) && (x < width) && (y >= 0) && (y < height)) {
+            cell.setTo(logical[x][y]);
+        }
+        return cell;
+    }
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void putAttrXY(final int x, final int y,
+        final CellAttributes attr) {
+
+        putAttrXY(x, y, attr, true);
+    }
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     * @param clip if true, honor clipping/offset
+     */
+    public final void putAttrXY(final int x, final int y,
+        final CellAttributes attr, final boolean clip) {
+
+        int X = x;
+        int Y = y;
+
+        if (clip) {
+            if ((x < clipLeft)
+                || (x >= clipRight)
+                || (y < clipTop)
+                || (y >= clipBottom)
+            ) {
+                return;
+            }
+            X += offsetX;
+            Y += offsetY;
+        }
+
+        if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+            logical[X][Y].setTo(attr);
+
+            // If this happens to be the cursor position, make the position
+            // dirty.
+            if ((cursorX == X) && (cursorY == Y)) {
+                physical[cursorX][cursorY].unset();
+                unsetImageRow(cursorY);
+            }
+        }
+    }
+
+    /**
+     * Fill the entire screen with one character with attributes.
+     *
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void putAll(final int ch, final CellAttributes attr) {
+
+        for (int x = 0; x < width; x++) {
+            for (int y = 0; y < height; y++) {
+                putCharXY(x, y, ch, attr);
+            }
+        }
+    }
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character + attributes to draw
+     */
+    public final void putCharXY(final int x, final int y, final Cell ch) {
+        if ((x < clipLeft)
+            || (x >= clipRight)
+            || (y < clipTop)
+            || (y >= clipBottom)
+        ) {
+            return;
+        }
+
+        if ((StringUtils.width(ch.getChar()) == 2) && (!ch.isImage())) {
+            putFullwidthCharXY(x, y, ch);
+            return;
+        }
+
+        int X = x + offsetX;
+        int Y = y + offsetY;
+
+        // System.err.printf("putCharXY: %d, %d, %c\n", X, Y, ch);
+
+        if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+
+            // Do not put control characters on the display
+            if (!ch.isImage()) {
+                assert (ch.getChar() >= 0x20);
+                assert (ch.getChar() != 0x7F);
+            }
+            logical[X][Y].setTo(ch);
+
+            // If this happens to be the cursor position, make the position
+            // dirty.
+            if ((cursorX == X) && (cursorY == Y)) {
+                physical[cursorX][cursorY].unset();
+                unsetImageRow(cursorY);
+            }
+        }
+    }
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void putCharXY(final int x, final int y, final int ch,
+        final CellAttributes attr) {
+
+        if ((x < clipLeft)
+            || (x >= clipRight)
+            || (y < clipTop)
+            || (y >= clipBottom)
+        ) {
+            return;
+        }
+
+        if (StringUtils.width(ch) == 2) {
+            putFullwidthCharXY(x, y, ch, attr);
+            return;
+        }
+
+        int X = x + offsetX;
+        int Y = y + offsetY;
+
+        // System.err.printf("putCharXY: %d, %d, %c\n", X, Y, ch);
+
+        if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+
+            // Do not put control characters on the display
+            assert (ch >= 0x20);
+            assert (ch != 0x7F);
+
+            logical[X][Y].setTo(attr);
+            logical[X][Y].setChar(ch);
+
+            // If this happens to be the cursor position, make the position
+            // dirty.
+            if ((cursorX == X) && (cursorY == Y)) {
+                physical[cursorX][cursorY].unset();
+                unsetImageRow(cursorY);
+            }
+        }
+    }
+
+    /**
+     * Render one character without changing the underlying attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     */
+    public final void putCharXY(final int x, final int y, final int ch) {
+        if ((x < clipLeft)
+            || (x >= clipRight)
+            || (y < clipTop)
+            || (y >= clipBottom)
+        ) {
+            return;
+        }
+
+        if (StringUtils.width(ch) == 2) {
+            putFullwidthCharXY(x, y, ch);
+            return;
+        }
+
+        int X = x + offsetX;
+        int Y = y + offsetY;
+
+        // System.err.printf("putCharXY: %d, %d, %c\n", X, Y, ch);
+
+        if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+            logical[X][Y].setChar(ch);
+
+            // If this happens to be the cursor position, make the position
+            // dirty.
+            if ((cursorX == X) && (cursorY == Y)) {
+                physical[cursorX][cursorY].unset();
+                unsetImageRow(cursorY);
+            }
+        }
+    }
+
+    /**
+     * Render a string.  Does not wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void putStringXY(final int x, final int y, final String str,
+        final CellAttributes attr) {
+
+        int i = x;
+        for (int j = 0; j < str.length();) {
+            int ch = str.codePointAt(j);
+            j += Character.charCount(ch);
+            putCharXY(i, y, ch, attr);
+            i += StringUtils.width(ch);
+            if (i == width) {
+                break;
+            }
+        }
+    }
+
+    /**
+     * Render a string without changing the underlying attribute.  Does not
+     * wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     */
+    public final void putStringXY(final int x, final int y, final String str) {
+
+        int i = x;
+        for (int j = 0; j < str.length();) {
+            int ch = str.codePointAt(j);
+            j += Character.charCount(ch);
+            putCharXY(i, y, ch);
+            i += StringUtils.width(ch);
+            if (i == width) {
+                break;
+            }
+        }
+    }
+
+    /**
+     * Draw a vertical line from (x, y) to (x, y + n).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void vLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr) {
+
+        for (int i = y; i < y + n; i++) {
+            putCharXY(x, i, ch, attr);
+        }
+    }
+
+    /**
+     * Draw a horizontal line from (x, y) to (x + n, y).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void hLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr) {
+
+        for (int i = x; i < x + n; i++) {
+            putCharXY(i, y, ch, attr);
+        }
+    }
+
+    /**
+     * Change the width.  Everything on-screen will be destroyed and must be
+     * redrawn.
+     *
+     * @param width new screen width
+     */
+    public final synchronized void setWidth(final int width) {
+        reallocate(width, this.height);
+    }
+
+    /**
+     * Change the height.  Everything on-screen will be destroyed and must be
+     * redrawn.
+     *
+     * @param height new screen height
+     */
+    public final synchronized void setHeight(final int height) {
+        reallocate(this.width, height);
+    }
+
+    /**
+     * Change the width and height.  Everything on-screen will be destroyed
+     * and must be redrawn.
+     *
+     * @param width new screen width
+     * @param height new screen height
+     */
+    public final void setDimensions(final int width, final int height) {
+        reallocate(width, height);
+        resizeToScreen();
+    }
+
+    /**
+     * Resize the physical screen to match the logical screen dimensions.
+     */
+    public void resizeToScreen() {
+        // Subclasses are expected to override this.
+    }
+
+    /**
+     * Get the height.
+     *
+     * @return current screen height
+     */
+    public final synchronized int getHeight() {
+        return this.height;
+    }
+
+    /**
+     * Get the width.
+     *
+     * @return current screen width
+     */
+    public final synchronized int getWidth() {
+        return this.width;
+    }
+
+    /**
+     * Reset screen to not-bold, white-on-black.  Also flushes the offset and
+     * clip variables.
+     */
+    public final synchronized void reset() {
+        for (int row = 0; row < height; row++) {
+            for (int col = 0; col < width; col++) {
+                logical[col][row].reset();
+            }
+        }
+        resetClipping();
+    }
+
+    /**
+     * Flush the offset and clip variables.
+     */
+    public final void resetClipping() {
+        offsetX    = 0;
+        offsetY    = 0;
+        clipLeft   = 0;
+        clipTop    = 0;
+        clipRight  = width;
+        clipBottom = height;
+    }
+
+    /**
+     * Clear the logical screen.
+     */
+    public final void clear() {
+        reset();
+    }
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most column.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     */
+    public final void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background) {
+
+        drawBox(left, top, right, bottom, border, background, 1, false);
+    }
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most column.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     * @param borderType if 1, draw a single-line border; if 2, draw a
+     * double-line border; if 3, draw double-line top/bottom edges and
+     * single-line left/right edges (like Qmodem)
+     * @param shadow if true, draw a "shadow" on the box
+     */
+    public final void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background,
+        final int borderType, final boolean shadow) {
+
+        int boxWidth = right - left;
+        int boxHeight = bottom - top;
+
+        char cTopLeft;
+        char cTopRight;
+        char cBottomLeft;
+        char cBottomRight;
+        char cHSide;
+        char cVSide;
+
+        switch (borderType) {
+        case 1:
+            cTopLeft = GraphicsChars.ULCORNER;
+            cTopRight = GraphicsChars.URCORNER;
+            cBottomLeft = GraphicsChars.LLCORNER;
+            cBottomRight = GraphicsChars.LRCORNER;
+            cHSide = GraphicsChars.SINGLE_BAR;
+            cVSide = GraphicsChars.WINDOW_SIDE;
+            break;
+
+        case 2:
+            cTopLeft = GraphicsChars.WINDOW_LEFT_TOP_DOUBLE;
+            cTopRight = GraphicsChars.WINDOW_RIGHT_TOP_DOUBLE;
+            cBottomLeft = GraphicsChars.WINDOW_LEFT_BOTTOM_DOUBLE;
+            cBottomRight = GraphicsChars.WINDOW_RIGHT_BOTTOM_DOUBLE;
+            cHSide = GraphicsChars.DOUBLE_BAR;
+            cVSide = GraphicsChars.WINDOW_SIDE_DOUBLE;
+            break;
+
+        case 3:
+            cTopLeft = GraphicsChars.WINDOW_LEFT_TOP;
+            cTopRight = GraphicsChars.WINDOW_RIGHT_TOP;
+            cBottomLeft = GraphicsChars.WINDOW_LEFT_BOTTOM;
+            cBottomRight = GraphicsChars.WINDOW_RIGHT_BOTTOM;
+            cHSide = GraphicsChars.WINDOW_TOP;
+            cVSide = GraphicsChars.WINDOW_SIDE;
+            break;
+        default:
+            throw new IllegalArgumentException("Invalid border type: "
+                + borderType);
+        }
+
+        // Place the corner characters
+        putCharXY(left, top, cTopLeft, border);
+        putCharXY(left + boxWidth - 1, top, cTopRight, border);
+        putCharXY(left, top + boxHeight - 1, cBottomLeft, border);
+        putCharXY(left + boxWidth - 1, top + boxHeight - 1, cBottomRight,
+            border);
+
+        // Draw the box lines
+        hLineXY(left + 1, top, boxWidth - 2, cHSide, border);
+        vLineXY(left, top + 1, boxHeight - 2, cVSide, border);
+        hLineXY(left + 1, top + boxHeight - 1, boxWidth - 2, cHSide, border);
+        vLineXY(left + boxWidth - 1, top + 1, boxHeight - 2, cVSide, border);
+
+        // Fill in the interior background
+        for (int i = 1; i < boxHeight - 1; i++) {
+            hLineXY(1 + left, i + top, boxWidth - 2, ' ', background);
+        }
+
+        if (shadow) {
+            // Draw a shadow
+            drawBoxShadow(left, top, right, bottom);
+        }
+    }
+
+    /**
+     * Draw a box shadow.
+     *
+     * @param left left column of box.  0 is the left-most column.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     */
+    public final void drawBoxShadow(final int left, final int top,
+        final int right, final int bottom) {
+
+        int boxTop = top;
+        int boxLeft = left;
+        int boxWidth = right - left;
+        int boxHeight = bottom - top;
+        CellAttributes shadowAttr = new CellAttributes();
+
+        // Shadows do not honor clipping but they DO honor offset.
+        int oldClipRight = clipRight;
+        int oldClipBottom = clipBottom;
+        // When offsetX or offsetY go negative, we need to increase the clip
+        // bounds.
+        clipRight = width - offsetX;
+        clipBottom = height - offsetY;
+
+        for (int i = 0; i < boxHeight; i++) {
+            Cell cell = getCharXY(offsetX + boxLeft + boxWidth,
+                offsetY + boxTop + 1 + i);
+            if (cell.getWidth() == Cell.Width.SINGLE) {
+                putAttrXY(boxLeft + boxWidth, boxTop + 1 + i, shadowAttr);
+            } else {
+                putCharXY(boxLeft + boxWidth, boxTop + 1 + i, ' ', shadowAttr);
+            }
+            cell = getCharXY(offsetX + boxLeft + boxWidth + 1,
+                offsetY + boxTop + 1 + i);
+            if (cell.getWidth() == Cell.Width.SINGLE) {
+                putAttrXY(boxLeft + boxWidth + 1, boxTop + 1 + i, shadowAttr);
+            } else {
+                putCharXY(boxLeft + boxWidth + 1, boxTop + 1 + i, ' ',
+                    shadowAttr);
+            }
+        }
+        for (int i = 0; i < boxWidth; i++) {
+            Cell cell = getCharXY(offsetX + boxLeft + 2 + i,
+                offsetY + boxTop + boxHeight);
+            if (cell.getWidth() == Cell.Width.SINGLE) {
+                putAttrXY(boxLeft + 2 + i, boxTop + boxHeight, shadowAttr);
+            } else {
+                putCharXY(boxLeft + 2 + i, boxTop + boxHeight, ' ', shadowAttr);
+            }
+        }
+        clipRight = oldClipRight;
+        clipBottom = oldClipBottom;
+    }
+
+    /**
+     * Default implementation does nothing.
+     */
+    public void flushPhysical() {}
+
+    /**
+     * Put the cursor at (x,y).
+     *
+     * @param visible if true, the cursor should be visible
+     * @param x column coordinate to put the cursor on
+     * @param y row coordinate to put the cursor on
+     */
+    public void putCursor(final boolean visible, final int x, final int y) {
+        if ((cursorY >= 0)
+            && (cursorX >= 0)
+            && (cursorY <= height - 1)
+            && (cursorX <= width - 1)
+        ) {
+            // Make the current cursor position dirty
+            physical[cursorX][cursorY].unset();
+            unsetImageRow(cursorY);
+        }
+
+        cursorVisible = visible;
+        cursorX = x;
+        cursorY = y;
+    }
+
+    /**
+     * Hide the cursor.
+     */
+    public final void hideCursor() {
+        cursorVisible = false;
+    }
+
+    /**
+     * Get the cursor visibility.
+     *
+     * @return true if the cursor is visible
+     */
+    public boolean isCursorVisible() {
+        return cursorVisible;
+    }
+
+    /**
+     * Get the cursor X position.
+     *
+     * @return the cursor x column position
+     */
+    public int getCursorX() {
+        return cursorX;
+    }
+
+    /**
+     * Get the cursor Y position.
+     *
+     * @return the cursor y row position
+     */
+    public int getCursorY() {
+        return cursorY;
+    }
+
+    /**
+     * Set the window title.  Default implementation does nothing.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title) {}
+
+    // ------------------------------------------------------------------------
+    // LogicalScreen ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Reallocate screen buffers.
+     *
+     * @param width new width
+     * @param height new height
+     */
+    private synchronized void reallocate(final int width, final int height) {
+        if (logical != null) {
+            for (int row = 0; row < this.height; row++) {
+                for (int col = 0; col < this.width; col++) {
+                    logical[col][row] = null;
+                }
+            }
+            logical = null;
+        }
+        logical = new Cell[width][height];
+        if (physical != null) {
+            for (int row = 0; row < this.height; row++) {
+                for (int col = 0; col < this.width; col++) {
+                    physical[col][row] = null;
+                }
+            }
+            physical = null;
+        }
+        physical = new Cell[width][height];
+
+        for (int row = 0; row < height; row++) {
+            for (int col = 0; col < width; col++) {
+                physical[col][row] = new Cell();
+                logical[col][row] = new Cell();
+            }
+        }
+
+        this.width = width;
+        this.height = height;
+
+        clipLeft = 0;
+        clipTop = 0;
+        clipRight = width;
+        clipBottom = height;
+
+        reallyCleared = true;
+    }
+
+    /**
+     * Clear the physical screen.
+     */
+    public final void clearPhysical() {
+        for (int row = 0; row < height; row++) {
+            for (int col = 0; col < width; col++) {
+                physical[col][row].unset();
+            }
+        }
+    }
+
+    /**
+     * Unset every image cell on one row of the physical screen, forcing
+     * images on that row to be redrawn.
+     *
+     * @param y row coordinate.  0 is the top-most row.
+     */
+    public final void unsetImageRow(final int y) {
+        if ((y < 0) || (y >= height)) {
+            return;
+        }
+        for (int x = 0; x < width; x++) {
+            if (logical[x][y].isImage()) {
+                physical[x][y].unset();
+            }
+        }
+    }
+
+    /**
+     * Render one fullwidth cell.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cell the cell to draw
+     */
+    public final void putFullwidthCharXY(final int x, final int y,
+        final Cell cell) {
+
+        int cellWidth = getTextWidth();
+        int cellHeight = getTextHeight();
+
+        if (lastTextHeight != cellHeight) {
+            glyphMaker = GlyphMaker.getInstance(cellHeight);
+            lastTextHeight = cellHeight;
+        }
+        BufferedImage image = glyphMaker.getImage(cell, cellWidth * 2,
+            cellHeight);
+        BufferedImage leftImage = image.getSubimage(0, 0, cellWidth,
+            cellHeight);
+        BufferedImage rightImage = image.getSubimage(cellWidth, 0, cellWidth,
+            cellHeight);
+
+        Cell left = new Cell(cell);
+        left.setImage(leftImage);
+        left.setWidth(Cell.Width.LEFT);
+        putCharXY(x, y, left);
+
+        Cell right = new Cell(cell);
+        right.setImage(rightImage);
+        right.setWidth(Cell.Width.RIGHT);
+        putCharXY(x + 1, y, right);
+    }
+
+    /**
+     * Render one fullwidth character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public final void putFullwidthCharXY(final int x, final int y,
+        final int ch, final CellAttributes attr) {
+
+        Cell cell = new Cell(ch, attr);
+        putFullwidthCharXY(x, y, cell);
+    }
+
+    /**
+     * Render one fullwidth character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     */
+    public final void putFullwidthCharXY(final int x, final int y,
+        final int ch) {
+
+        Cell cell = new Cell(ch);
+        cell.setAttr(getAttrXY(x, y));
+        putFullwidthCharXY(x, y, cell);
+    }
+
+    /**
+     * Invert the cell color at a position, including both halves of a
+     * double-width cell.
+     *
+     * @param x column position
+     * @param y row position
+     */
+    public void invertCell(final int x, final int y) {
+        invertCell(x, y, false);
+    }
+
+    /**
+     * Invert the cell color at a position.
+     *
+     * @param x column position
+     * @param y row position
+     * @param onlyThisCell if true, only invert this cell, otherwise invert
+     * both halves of a double-width cell if necessary
+     */
+    public void invertCell(final int x, final int y,
+        final boolean onlyThisCell) {
+
+        Cell cell = getCharXY(x, y);
+        if (cell.isImage()) {
+            cell.invertImage();
+        }
+        if (cell.getForeColorRGB() < 0) {
+            cell.setForeColor(cell.getForeColor().invert());
+        } else {
+            cell.setForeColorRGB(cell.getForeColorRGB() ^ 0x00ffffff);
+        }
+        if (cell.getBackColorRGB() < 0) {
+            cell.setBackColor(cell.getBackColor().invert());
+        } else {
+            cell.setBackColorRGB(cell.getBackColorRGB() ^ 0x00ffffff);
+        }
+        putCharXY(x, y, cell);
+        if ((onlyThisCell == true) || (cell.getWidth() == Cell.Width.SINGLE)) {
+            return;
+        }
+
+        // This cell is one half of a fullwidth glyph.  Invert the other
+        // half.
+        if (cell.getWidth() == Cell.Width.LEFT) {
+            if (x < width - 1) {
+                Cell rightHalf = getCharXY(x + 1, y);
+                if (rightHalf.getWidth() == Cell.Width.RIGHT) {
+                    invertCell(x + 1, y, true);
+                    return;
+                }
+            }
+        }
+        if (cell.getWidth() == Cell.Width.RIGHT) {
+            if (x > 0) {
+                Cell leftHalf = getCharXY(x - 1, y);
+                if (leftHalf.getWidth() == Cell.Width.LEFT) {
+                    invertCell(x - 1, y, true);
+                }
+            }
+        }
+    }
+
+    /**
+     * Set a selection area on the screen.
+     *
+     * @param x0 the starting X position of the selection
+     * @param y0 the starting Y position of the selection
+     * @param x1 the ending X position of the selection
+     * @param y1 the ending Y position of the selection
+     * @param rectangle if true, this is a rectangle select
+     */
+    public void setSelection(final int x0, final int y0,
+        final int x1, final int y1, final boolean rectangle) {
+
+        int startX = x0;
+        int startY = y0;
+        int endX = x1;
+        int endY = y1;
+
+        if (((x1 < x0) && (y1 == y0))
+            || (y1 < y0)
+        ) {
+            // The user dragged from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startX = x1;
+            startY = y1;
+            endX = x0;
+            endY = y0;
+        }
+        if (rectangle) {
+            for (int y = startY; y <= endY; y++) {
+                for (int x = startX; x <= endX; x++) {
+                    invertCell(x, y);
+                }
+            }
+        } else {
+            if (endY > startY) {
+                for (int x = startX; x < width; x++) {
+                    invertCell(x, startY);
+                }
+                for (int y = startY + 1; y < endY; y++) {
+                    for (int x = 0; x < width; x++) {
+                        invertCell(x, y);
+                    }
+                }
+                for (int x = 0; x <= endX; x++) {
+                    invertCell(x, endY);
+                }
+            } else {
+                assert (startY == endY);
+                for (int x = startX; x <= endX; x++) {
+                    invertCell(x, startY);
+                }
+            }
+        }
+    }
+
+    /**
+     * Copy the screen selection area to the clipboard.
+     *
+     * @param clipboard the clipboard to use
+     * @param x0 the starting X position of the selection
+     * @param y0 the starting Y position of the selection
+     * @param x1 the ending X position of the selection
+     * @param y1 the ending Y position of the selection
+     * @param rectangle if true, this is a rectangle select
+     */
+    public void copySelection(final Clipboard clipboard,
+        final int x0, final int y0, final int x1, final int y1,
+        final boolean rectangle) {
+
+        StringBuilder sb = new StringBuilder();
+
+        int startX = x0;
+        int startY = y0;
+        int endX = x1;
+        int endY = y1;
+
+        if (((x1 < x0) && (y1 == y0))
+            || (y1 < y0)
+        ) {
+            // The user dragged from bottom-to-top and/or right-to-left.
+            // Reverse the coordinates for the inverted section.
+            startX = x1;
+            startY = y1;
+            endX = x0;
+            endY = y0;
+        }
+        if (rectangle) {
+            for (int y = startY; y <= endY; y++) {
+                for (int x = startX; x <= endX; x++) {
+                    sb.append(Character.toChars(getCharXY(x, y).getChar()));
+                }
+                sb.append("\n");
+            }
+        } else {
+            if (endY > startY) {
+                for (int x = startX; x < width; x++) {
+                    sb.append(Character.toChars(getCharXY(x, startY).getChar()));
+                }
+                sb.append("\n");
+                for (int y = startY + 1; y < endY; y++) {
+                    for (int x = 0; x < width; x++) {
+                        sb.append(Character.toChars(getCharXY(x, y).getChar()));
+                    }
+                    sb.append("\n");
+                }
+                for (int x = 0; x <= endX; x++) {
+                    sb.append(Character.toChars(getCharXY(x, endY).getChar()));
+                }
+            } else {
+                assert (startY == endY);
+                for (int x = startX; x <= endX; x++) {
+                    sb.append(Character.toChars(getCharXY(x, startY).getChar()));
+                }
+            }
+        }
+        clipboard.copyText(sb.toString());
+    }
+
+}
diff --git a/src/jexer/backend/MultiBackend.java b/src/jexer/backend/MultiBackend.java
new file mode 100644 (file)
index 0000000..d01b944
--- /dev/null
@@ -0,0 +1,254 @@
+/*
+ * 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.backend;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.event.TCommandEvent;
+import jexer.event.TInputEvent;
+import static jexer.TCommand.*;
+
+/**
+ * MultiBackend mirrors its I/O to several backends.
+ */
+public class MultiBackend implements Backend {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The screen to use.
+     */
+    private MultiScreen multiScreen;
+
+    /**
+     * The list of backends to use.
+     */
+    private List<Backend> backends = new ArrayList<Backend>();
+
+    /**
+     * The SessionInfo to return.
+     */
+    private SessionInfo sessionInfo;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor requires one backend.  Note that this backend's
+     * screen will be replaced with a MultiScreen.
+     *
+     * @param backend the backend to add
+     */
+    public MultiBackend(final Backend backend) {
+        backends.add(backend);
+        if (backend instanceof TWindowBackend) {
+            multiScreen = new MultiScreen(((TWindowBackend) backend).getOtherScreen());
+        } else {
+            multiScreen = new MultiScreen(backend.getScreen());
+        }
+        if (backend instanceof GenericBackend) {
+            ((GenericBackend) backend).abortOnDisconnect = false;
+        }
+        sessionInfo = backend.getSessionInfo();
+    }
+
+    // ------------------------------------------------------------------------
+    // Backend ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Getter for sessionInfo.
+     *
+     * @return the SessionInfo
+     */
+    public SessionInfo getSessionInfo() {
+        return sessionInfo;
+    }
+
+    /**
+     * Getter for screen.
+     *
+     * @return the Screen
+     */
+    public Screen getScreen() {
+        return multiScreen;
+    }
+
+    /**
+     * Subclasses must provide an implementation that syncs the logical
+     * screen to the physical device.
+     */
+    public void flushScreen() {
+        for (Backend backend: backends) {
+            backend.flushScreen();
+        }
+    }
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the application
+     */
+    public boolean hasEvents() {
+        if (backends.size() == 0) {
+            return true;
+        }
+        for (Backend backend: backends) {
+            if (backend.hasEvents()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Subclasses must provide an implementation to get keyboard, mouse, and
+     * screen resize events.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(List<TInputEvent> queue) {
+        List<Backend> backendsToRemove = null;
+        for (Backend backend: backends) {
+            if (backend.hasEvents()) {
+                backend.getEvents(queue);
+
+                // This default backend assumes a single user, and if that
+                // user becomes disconnected we should terminate the
+                // application.
+                if (queue.size() > 0) {
+                    TInputEvent event = queue.get(queue.size() - 1);
+                    if (event instanceof TCommandEvent) {
+                        TCommandEvent command = (TCommandEvent) event;
+                        if (command.equals(cmBackendDisconnect)) {
+                            if (backendsToRemove == null) {
+                                backendsToRemove = new ArrayList<Backend>();
+                            }
+                            backendsToRemove.add(backend);
+                        }
+                    }
+                }
+            }
+        }
+        if (backendsToRemove != null) {
+            for (Backend backend: backendsToRemove) {
+                multiScreen.removeScreen(backend.getScreen());
+                backends.remove(backend);
+                backend.shutdown();
+            }
+        }
+        if (backends.size() == 0) {
+            queue.add(new TCommandEvent(cmAbort));
+        }
+    }
+
+    /**
+     * Subclasses must provide an implementation that closes sockets,
+     * restores console, etc.
+     */
+    public void shutdown() {
+        for (Backend backend: backends) {
+            backend.shutdown();
+        }
+    }
+
+    /**
+     * Subclasses must provide an implementation that sets the window title.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title) {
+        for (Backend backend: backends) {
+            backend.setTitle(title);
+        }
+    }
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener) {
+        for (Backend backend: backends) {
+            backend.setListener(listener);
+        }
+    }
+
+    /**
+     * Reload backend options from System properties.
+     */
+    public void reloadOptions() {
+        for (Backend backend: backends) {
+            backend.reloadOptions();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // MultiBackend -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Add a backend to the list.
+     *
+     * @param backend the backend to add
+     */
+    public void addBackend(final Backend backend) {
+        backends.add(backend);
+        if (backend instanceof TWindowBackend) {
+            multiScreen.addScreen(((TWindowBackend) backend).getOtherScreen());
+        } else {
+            multiScreen.addScreen(backend.getScreen());
+        }
+        if (backend instanceof GenericBackend) {
+            ((GenericBackend) backend).abortOnDisconnect = false;
+        }
+    }
+
+    /**
+     * Remove a backend from the list.
+     *
+     * @param backend the backend to remove
+     */
+    public void removeBackend(final Backend backend) {
+        if (backends.size() > 1) {
+            if (backend instanceof TWindowBackend) {
+                multiScreen.removeScreen(((TWindowBackend) backend).getOtherScreen());
+            } else {
+                multiScreen.removeScreen(backend.getScreen());
+            }
+            backends.remove(backend);
+        }
+    }
+
+}
diff --git a/src/jexer/backend/MultiScreen.java b/src/jexer/backend/MultiScreen.java
new file mode 100644 (file)
index 0000000..45741c0
--- /dev/null
@@ -0,0 +1,773 @@
+/*
+ * 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.backend;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.Clipboard;
+
+/**
+ * MultiScreen mirrors its I/O to several screens.
+ */
+public class MultiScreen implements Screen {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The list of screens to use.
+     */
+    private List<Screen> screens = new ArrayList<Screen>();
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor requires one screen.
+     *
+     * @param screen the screen to add
+     */
+    public MultiScreen(final Screen screen) {
+        screens.add(screen);
+    }
+
+    // ------------------------------------------------------------------------
+    // Screen -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set drawing offset for x.
+     *
+     * @param offsetX new drawing offset
+     */
+    public void setOffsetX(final int offsetX) {
+        for (Screen screen: screens) {
+            screen.setOffsetX(offsetX);
+        }
+    }
+
+    /**
+     * Set drawing offset for y.
+     *
+     * @param offsetY new drawing offset
+     */
+    public void setOffsetY(final int offsetY) {
+        for (Screen screen: screens) {
+            screen.setOffsetY(offsetY);
+        }
+    }
+
+    /**
+     * Get right drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipRight() {
+        if (screens.size() > 0) {
+            return screens.get(0).getClipRight();
+        }
+        return 0;
+    }
+
+    /**
+     * Set right drawing clipping boundary.
+     *
+     * @param clipRight new boundary
+     */
+    public void setClipRight(final int clipRight) {
+        for (Screen screen: screens) {
+            screen.setClipRight(clipRight);
+        }
+    }
+
+    /**
+     * Get bottom drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipBottom() {
+        if (screens.size() > 0) {
+            return screens.get(0).getClipBottom();
+        }
+        return 0;
+    }
+
+    /**
+     * Set bottom drawing clipping boundary.
+     *
+     * @param clipBottom new boundary
+     */
+    public void setClipBottom(final int clipBottom) {
+        for (Screen screen: screens) {
+            screen.setClipBottom(clipBottom);
+        }
+    }
+
+    /**
+     * Get left drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipLeft() {
+        if (screens.size() > 0) {
+            return screens.get(0).getClipLeft();
+        }
+        return 0;
+    }
+
+    /**
+     * Set left drawing clipping boundary.
+     *
+     * @param clipLeft new boundary
+     */
+    public void setClipLeft(final int clipLeft) {
+        for (Screen screen: screens) {
+            screen.setClipLeft(clipLeft);
+        }
+    }
+
+    /**
+     * Get top drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipTop() {
+        if (screens.size() > 0) {
+            return screens.get(0).getClipTop();
+        }
+        return 0;
+    }
+
+    /**
+     * Set top drawing clipping boundary.
+     *
+     * @param clipTop new boundary
+     */
+    public void setClipTop(final int clipTop) {
+        for (Screen screen: screens) {
+            screen.setClipTop(clipTop);
+        }
+    }
+
+    /**
+     * Get dirty flag.
+     *
+     * @return if true, the logical screen is not in sync with the physical
+     * screen
+     */
+    public boolean isDirty() {
+        for (Screen screen: screens) {
+            if (screen.isDirty()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Get the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return attributes at (x, y)
+     */
+    public CellAttributes getAttrXY(final int x, final int y) {
+        if (screens.size() > 0) {
+            return screens.get(0).getAttrXY(x, y);
+        }
+        return new CellAttributes();
+    }
+
+    /**
+     * Get the cell at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return the character + attributes
+     */
+    public Cell getCharXY(final int x, final int y) {
+        if (screens.size() > 0) {
+            return screens.get(0).getCharXY(x, y);
+        }
+        return new Cell();
+    }
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putAttrXY(final int x, final int y,
+        final CellAttributes attr) {
+
+        for (Screen screen: screens) {
+            screen.putAttrXY(x, y, attr);
+        }
+    }
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     * @param clip if true, honor clipping/offset
+     */
+    public void putAttrXY(final int x, final int y,
+        final CellAttributes attr, final boolean clip) {
+
+        for (Screen screen: screens) {
+            screen.putAttrXY(x, y, attr, clip);
+        }
+    }
+
+    /**
+     * Fill the entire screen with one character with attributes.
+     *
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putAll(final int ch, final CellAttributes attr) {
+        for (Screen screen: screens) {
+            screen.putAll(ch, attr);
+        }
+    }
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character + attributes to draw
+     */
+    public void putCharXY(final int x, final int y, final Cell ch) {
+        for (Screen screen: screens) {
+            screen.putCharXY(x, y, ch);
+        }
+    }
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putCharXY(final int x, final int y, final int ch,
+        final CellAttributes attr) {
+
+        for (Screen screen: screens) {
+            screen.putCharXY(x, y, ch, attr);
+        }
+    }
+
+    /**
+     * Render one character without changing the underlying attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     */
+    public void putCharXY(final int x, final int y, final int ch) {
+        for (Screen screen: screens) {
+            screen.putCharXY(x, y, ch);
+        }
+    }
+
+    /**
+     * Render a string.  Does not wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putStringXY(final int x, final int y, final String str,
+        final CellAttributes attr) {
+
+        for (Screen screen: screens) {
+            screen.putStringXY(x, y, str, attr);
+        }
+    }
+
+    /**
+     * Render a string without changing the underlying attribute.  Does not
+     * wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     */
+    public void putStringXY(final int x, final int y, final String str) {
+        for (Screen screen: screens) {
+            screen.putStringXY(x, y, str);
+        }
+    }
+
+    /**
+     * Draw a vertical line from (x, y) to (x, y + n).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void vLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr) {
+
+        for (Screen screen: screens) {
+            screen.vLineXY(x, y, n, ch, attr);
+        }
+    }
+
+    /**
+     * Draw a horizontal line from (x, y) to (x + n, y).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void hLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr) {
+
+        for (Screen screen: screens) {
+            screen.hLineXY(x, y, n, ch, attr);
+        }
+    }
+
+    /**
+     * Change the width.  Everything on-screen will be destroyed and must be
+     * redrawn.
+     *
+     * @param width new screen width
+     */
+    public void setWidth(final int width) {
+        for (Screen screen: screens) {
+            screen.setWidth(width);
+        }
+    }
+
+    /**
+     * Change the height.  Everything on-screen will be destroyed and must be
+     * redrawn.
+     *
+     * @param height new screen height
+     */
+    public void setHeight(final int height) {
+        for (Screen screen: screens) {
+            screen.setHeight(height);
+        }
+    }
+
+    /**
+     * Change the width and height.  Everything on-screen will be destroyed
+     * and must be redrawn.
+     *
+     * @param width new screen width
+     * @param height new screen height
+     */
+    public void setDimensions(final int width, final int height) {
+        for (Screen screen: screens) {
+            // Do not blindly call setDimension() on every screen.  Instead
+            // call it only on those screens that do not already have the
+            // requested dimension.  With this very small check, we have the
+            // ability for ANY screen in the MultiBackend to resize ALL of
+            // the screens.
+            if ((screen.getWidth() != width)
+                || (screen.getHeight() != height)
+            ) {
+                screen.setDimensions(width, height);
+            } else {
+                // The screen that didn't change is probably the one that
+                // prompted the resize.  Force it to repaint.
+                screen.clearPhysical();
+            }
+        }
+    }
+
+    /**
+     * Get the height.
+     *
+     * @return current screen height
+     */
+    public int getHeight() {
+        // Return the smallest height of the screens.
+        int height = 25;
+        if (screens.size() > 0) {
+            height = screens.get(0).getHeight();
+        }
+        for (Screen screen: screens) {
+            if (screen.getHeight() < height) {
+                height = screen.getHeight();
+            }
+        }
+        return height;
+    }
+
+    /**
+     * Get the width.
+     *
+     * @return current screen width
+     */
+    public int getWidth() {
+        // Return the smallest width of the screens.
+        int width = 80;
+        if (screens.size() > 0) {
+            width = screens.get(0).getWidth();
+        }
+        for (Screen screen: screens) {
+            if (screen.getWidth() < width) {
+                width = screen.getWidth();
+            }
+        }
+        return width;
+    }
+
+    /**
+     * Reset screen to not-bold, white-on-black.  Also flushes the offset and
+     * clip variables.
+     */
+    public void reset() {
+        for (Screen screen: screens) {
+            screen.reset();
+        }
+    }
+
+    /**
+     * Flush the offset and clip variables.
+     */
+    public void resetClipping() {
+        for (Screen screen: screens) {
+            screen.resetClipping();
+        }
+    }
+
+    /**
+     * Clear the logical screen.
+     */
+    public void clear() {
+        for (Screen screen: screens) {
+            screen.clear();
+        }
+    }
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     */
+    public void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background) {
+
+        for (Screen screen: screens) {
+            screen.drawBox(left, top, right, bottom, border, background);
+        }
+    }
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     * @param borderType if 1, draw a single-line border; if 2, draw a
+     * double-line border; if 3, draw double-line top/bottom edges and
+     * single-line left/right edges (like Qmodem)
+     * @param shadow if true, draw a "shadow" on the box
+     */
+    public void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background,
+        final int borderType, final boolean shadow) {
+
+        for (Screen screen: screens) {
+            screen.drawBox(left, top, right, bottom, border, background,
+                borderType, shadow);
+        }
+    }
+
+    /**
+     * Draw a box shadow.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     */
+    public void drawBoxShadow(final int left, final int top,
+        final int right, final int bottom) {
+
+        for (Screen screen: screens) {
+            screen.drawBoxShadow(left, top, right, bottom);
+        }
+    }
+
+    /**
+     * Clear the physical screen.
+     */
+    public void clearPhysical() {
+        for (Screen screen: screens) {
+            screen.clearPhysical();
+        }
+    }
+
+    /**
+     * Unset every image cell on one row of the physical screen, forcing
+     * images on that row to be redrawn.
+     *
+     * @param y row coordinate.  0 is the top-most row.
+     */
+    public final void unsetImageRow(final int y) {
+        for (Screen screen: screens) {
+            screen.unsetImageRow(y);
+        }
+    }
+
+    /**
+     * Classes must provide an implementation to push the logical screen to
+     * the physical device.
+     */
+    public void flushPhysical() {
+        for (Screen screen: screens) {
+            screen.flushPhysical();
+        }
+    }
+
+    /**
+     * Put the cursor at (x,y).
+     *
+     * @param visible if true, the cursor should be visible
+     * @param x column coordinate to put the cursor on
+     * @param y row coordinate to put the cursor on
+     */
+    public void putCursor(final boolean visible, final int x, final int y) {
+        for (Screen screen: screens) {
+            screen.putCursor(visible, x, y);
+        }
+    }
+
+    /**
+     * Hide the cursor.
+     */
+    public void hideCursor() {
+        for (Screen screen: screens) {
+            screen.hideCursor();
+        }
+    }
+
+    /**
+     * Get the cursor visibility.
+     *
+     * @return true if the cursor is visible
+     */
+    public boolean isCursorVisible() {
+        if (screens.size() > 0) {
+            return screens.get(0).isCursorVisible();
+        }
+        return true;
+    }
+
+    /**
+     * Get the cursor X position.
+     *
+     * @return the cursor x column position
+     */
+    public int getCursorX() {
+        if (screens.size() > 0) {
+            return screens.get(0).getCursorX();
+        }
+        return 0;
+    }
+
+    /**
+     * Get the cursor Y position.
+     *
+     * @return the cursor y row position
+     */
+    public int getCursorY() {
+        if (screens.size() > 0) {
+            return screens.get(0).getCursorY();
+        }
+        return 0;
+    }
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title) {
+        for (Screen screen: screens) {
+            screen.setTitle(title);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // MultiScreen ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Add a screen to the list.
+     *
+     * @param screen the screen to add
+     */
+    public void addScreen(final Screen screen) {
+        screens.add(screen);
+    }
+
+    /**
+     * Remove a screen from the list.
+     *
+     * @param screen the screen to remove
+     */
+    public void removeScreen(final Screen screen) {
+        if (screens.size() > 1) {
+            screens.remove(screen);
+        }
+    }
+
+    /**
+     * Get the width of a character cell in pixels.
+     *
+     * @return the width in pixels of a character cell
+     */
+    public int getTextWidth() {
+        int textWidth = 16;
+        for (Screen screen: screens) {
+            int newTextWidth = screen.getTextWidth();
+            if (newTextWidth < textWidth) {
+                textWidth = newTextWidth;
+            }
+        }
+        return textWidth;
+    }
+
+    /**
+     * Get the height of a character cell in pixels.
+     *
+     * @return the height in pixels of a character cell
+     */
+    public int getTextHeight() {
+        int textHeight = 20;
+        for (Screen screen: screens) {
+            int newTextHeight = screen.getTextHeight();
+            if (newTextHeight < textHeight) {
+                textHeight = newTextHeight;
+            }
+        }
+        return textHeight;
+    }
+
+    /**
+     * Invert the cell color at a position, including both halves of a
+     * double-width cell.
+     *
+     * @param x column position
+     * @param y row position
+     */
+    public void invertCell(final int x, final int y) {
+        for (Screen screen: screens) {
+            screen.invertCell(x, y);
+        }
+    }
+
+    /**
+     * Invert the cell color at a position.
+     *
+     * @param x column position
+     * @param y row position
+     * @param onlyThisCell if true, only invert this cell, otherwise invert
+     * both halves of a double-width cell if necessary
+     */
+    public void invertCell(final int x, final int y,
+        final boolean onlyThisCell) {
+
+        for (Screen screen: screens) {
+            screen.invertCell(x, y, onlyThisCell);
+        }
+    }
+
+    /**
+     * Set a selection area on the screen.
+     *
+     * @param x0 the starting X position of the selection
+     * @param y0 the starting Y position of the selection
+     * @param x1 the ending X position of the selection
+     * @param y1 the ending Y position of the selection
+     * @param rectangle if true, this is a rectangle select
+     */
+    public void setSelection(final int x0, final int y0,
+        final int x1, final int y1, final boolean rectangle) {
+
+        for (Screen screen: screens) {
+            screen.setSelection(x0, y0, x1, y1, rectangle);
+        }
+    }
+
+    /**
+     * Copy the screen selection area to the clipboard.
+     *
+     * @param clipboard the clipboard to use
+     * @param x0 the starting X position of the selection
+     * @param y0 the starting Y position of the selection
+     * @param x1 the ending X position of the selection
+     * @param y1 the ending Y position of the selection
+     * @param rectangle if true, this is a rectangle select
+     */
+    public void copySelection(final Clipboard clipboard,
+        final int x0, final int y0, final int x1, final int y1,
+        final boolean rectangle) {
+
+        // Only copy from the first screen.
+        if (screens.size() > 0) {
+            screens.get(0).copySelection(clipboard, x0, y0, x1, y1, rectangle);
+        }
+    }
+
+}
diff --git a/src/jexer/backend/Screen.java b/src/jexer/backend/Screen.java
new file mode 100644 (file)
index 0000000..a9a2053
--- /dev/null
@@ -0,0 +1,459 @@
+/*
+ * 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.backend;
+
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.Clipboard;
+
+/**
+ * Drawing operations API.
+ */
+public interface Screen {
+
+    /**
+     * Set drawing offset for x.
+     *
+     * @param offsetX new drawing offset
+     */
+    public void setOffsetX(final int offsetX);
+
+    /**
+     * Set drawing offset for y.
+     *
+     * @param offsetY new drawing offset
+     */
+    public void setOffsetY(final int offsetY);
+
+    /**
+     * Get right drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipRight();
+
+    /**
+     * Set right drawing clipping boundary.
+     *
+     * @param clipRight new boundary
+     */
+    public void setClipRight(final int clipRight);
+
+    /**
+     * Get bottom drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipBottom();
+
+    /**
+     * Set bottom drawing clipping boundary.
+     *
+     * @param clipBottom new boundary
+     */
+    public void setClipBottom(final int clipBottom);
+
+    /**
+     * Get left drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipLeft();
+
+    /**
+     * Set left drawing clipping boundary.
+     *
+     * @param clipLeft new boundary
+     */
+    public void setClipLeft(final int clipLeft);
+
+    /**
+     * Get top drawing clipping boundary.
+     *
+     * @return drawing boundary
+     */
+    public int getClipTop();
+
+    /**
+     * Set top drawing clipping boundary.
+     *
+     * @param clipTop new boundary
+     */
+    public void setClipTop(final int clipTop);
+
+    /**
+     * Get dirty flag.
+     *
+     * @return if true, the logical screen is not in sync with the physical
+     * screen
+     */
+    public boolean isDirty();
+
+    /**
+     * Get the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return attributes at (x, y)
+     */
+    public CellAttributes getAttrXY(final int x, final int y);
+
+    /**
+     * Get the cell at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return the character + attributes
+     */
+    public Cell getCharXY(final int x, final int y);
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putAttrXY(final int x, final int y,
+        final CellAttributes attr);
+
+    /**
+     * Set the attributes at one location.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param attr attributes to use (bold, foreColor, backColor)
+     * @param clip if true, honor clipping/offset
+     */
+    public void putAttrXY(final int x, final int y,
+        final CellAttributes attr, final boolean clip);
+
+    /**
+     * Fill the entire screen with one character with attributes.
+     *
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putAll(final int ch, final CellAttributes attr);
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character + attributes to draw
+     */
+    public void putCharXY(final int x, final int y, final Cell ch);
+
+    /**
+     * Render one character with attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putCharXY(final int x, final int y, final int ch,
+        final CellAttributes attr);
+
+    /**
+     * Render one character without changing the underlying attributes.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param ch character to draw
+     */
+    public void putCharXY(final int x, final int y, final int ch);
+
+    /**
+     * Render a string.  Does not wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void putStringXY(final int x, final int y, final String str,
+        final CellAttributes attr);
+
+    /**
+     * Render a string without changing the underlying attribute.  Does not
+     * wrap if the string exceeds the line.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param str string to draw
+     */
+    public void putStringXY(final int x, final int y, final String str);
+
+    /**
+     * Draw a vertical line from (x, y) to (x, y + n).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void vLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr);
+
+    /**
+     * Draw a horizontal line from (x, y) to (x + n, y).
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param n number of characters to draw
+     * @param ch character to draw
+     * @param attr attributes to use (bold, foreColor, backColor)
+     */
+    public void hLineXY(final int x, final int y, final int n,
+        final int ch, final CellAttributes attr);
+
+    /**
+     * Change the width.  Everything on-screen will be destroyed and must be
+     * redrawn.
+     *
+     * @param width new screen width
+     */
+    public void setWidth(final int width);
+
+    /**
+     * Change the height.  Everything on-screen will be destroyed and must be
+     * redrawn.
+     *
+     * @param height new screen height
+     */
+    public void setHeight(final int height);
+
+    /**
+     * Change the width and height.  Everything on-screen will be destroyed
+     * and must be redrawn.
+     *
+     * @param width new screen width
+     * @param height new screen height
+     */
+    public void setDimensions(final int width, final int height);
+
+    /**
+     * Get the height.
+     *
+     * @return current screen height
+     */
+    public int getHeight();
+
+    /**
+     * Get the width.
+     *
+     * @return current screen width
+     */
+    public int getWidth();
+
+    /**
+     * Reset screen to not-bold, white-on-black.  Also flushes the offset and
+     * clip variables.
+     */
+    public void reset();
+
+    /**
+     * Flush the offset and clip variables.
+     */
+    public void resetClipping();
+
+    /**
+     * Clear the logical screen.
+     */
+    public void clear();
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     */
+    public void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background);
+
+    /**
+     * Draw a box with a border and empty background.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     * @param border attributes to use for the border
+     * @param background attributes to use for the background
+     * @param borderType if 1, draw a single-line border; if 2, draw a
+     * double-line border; if 3, draw double-line top/bottom edges and
+     * single-line left/right edges (like Qmodem)
+     * @param shadow if true, draw a "shadow" on the box
+     */
+    public void drawBox(final int left, final int top,
+        final int right, final int bottom,
+        final CellAttributes border, final CellAttributes background,
+        final int borderType, final boolean shadow);
+
+    /**
+     * Draw a box shadow.
+     *
+     * @param left left column of box.  0 is the left-most row.
+     * @param top top row of the box.  0 is the top-most row.
+     * @param right right column of box
+     * @param bottom bottom row of the box
+     */
+    public void drawBoxShadow(final int left, final int top,
+        final int right, final int bottom);
+
+    /**
+     * Clear the physical screen.
+     */
+    public void clearPhysical();
+
+    /**
+     * Unset every image cell on one row of the physical screen, forcing
+     * images on that row to be redrawn.
+     *
+     * @param y row coordinate.  0 is the top-most row.
+     */
+    public void unsetImageRow(final int y);
+
+    /**
+     * Classes must provide an implementation to push the logical screen to
+     * the physical device.
+     */
+    public void flushPhysical();
+
+    /**
+     * Put the cursor at (x,y).
+     *
+     * @param visible if true, the cursor should be visible
+     * @param x column coordinate to put the cursor on
+     * @param y row coordinate to put the cursor on
+     */
+    public void putCursor(final boolean visible, final int x, final int y);
+
+    /**
+     * Hide the cursor.
+     */
+    public void hideCursor();
+
+    /**
+     * Get the cursor visibility.
+     *
+     * @return true if the cursor is visible
+     */
+    public boolean isCursorVisible();
+
+    /**
+     * Get the cursor X position.
+     *
+     * @return the cursor x column position
+     */
+    public int getCursorX();
+
+    /**
+     * Get the cursor Y position.
+     *
+     * @return the cursor y row position
+     */
+    public int getCursorY();
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title);
+
+    /**
+     * Get the width of a character cell in pixels.
+     *
+     * @return the width in pixels of a character cell
+     */
+    public int getTextWidth();
+
+    /**
+     * Get the height of a character cell in pixels.
+     *
+     * @return the height in pixels of a character cell
+     */
+    public int getTextHeight();
+
+    /**
+     * Invert the cell color at a position, including both halves of a
+     * double-width cell.
+     *
+     * @param x column position
+     * @param y row position
+     */
+    public void invertCell(final int x, final int y);
+
+    /**
+     * Invert the cell color at a position.
+     *
+     * @param x column position
+     * @param y row position
+     * @param onlyThisCell if true, only invert this cell, otherwise invert
+     * both halves of a double-width cell if necessary
+     */
+    public void invertCell(final int x, final int y,
+        final boolean onlyThisCell);
+
+    /**
+     * Set a selection area on the screen.
+     *
+     * @param x0 the starting X position of the selection
+     * @param y0 the starting Y position of the selection
+     * @param x1 the ending X position of the selection
+     * @param y1 the ending Y position of the selection
+     * @param rectangle if true, this is a rectangle select
+     */
+    public void setSelection(final int x0, final int y0,
+        final int x1, final int y1, final boolean rectangle);
+
+    /**
+     * Copy the screen selection area to the clipboard.
+     *
+     * @param clipboard the clipboard to use
+     * @param x0 the starting X position of the selection
+     * @param y0 the starting Y position of the selection
+     * @param x1 the ending X position of the selection
+     * @param y1 the ending Y position of the selection
+     * @param rectangle if true, this is a rectangle select
+     */
+    public void copySelection(final Clipboard clipboard,
+        final int x0, final int y0, final int x1, final int y1,
+        final boolean rectangle);
+
+}
diff --git a/src/jexer/backend/SessionInfo.java b/src/jexer/backend/SessionInfo.java
new file mode 100644 (file)
index 0000000..8a29ce0
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * 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.backend;
+
+/**
+ * SessionInfo is used to store per-session properties that are determined at
+ * different layers of the communication stack.
+ */
+public interface SessionInfo {
+
+    /**
+     * Username getter.
+     *
+     * @return the username
+     */
+    public String getUsername();
+
+    /**
+     * Username setter.
+     *
+     * @param username the value
+     */
+    public void setUsername(String username);
+
+    /**
+     * Language getter.
+     *
+     * @return the language
+     */
+    public String getLanguage();
+
+    /**
+     * Language setter.
+     *
+     * @param language the value
+     */
+    public void setLanguage(String language);
+
+    /**
+     * Text window width getter.
+     *
+     * @return the window width
+     */
+    public int getWindowWidth();
+
+    /**
+     * Text window height getter.
+     *
+     * @return the window height
+     */
+    public int getWindowHeight();
+
+    /**
+     * Re-query the text window size.
+     */
+    public void queryWindowSize();
+}
diff --git a/src/jexer/backend/SwingBackend.java b/src/jexer/backend/SwingBackend.java
new file mode 100644 (file)
index 0000000..8a342b6
--- /dev/null
@@ -0,0 +1,167 @@
+/*
+ * 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.backend;
+
+import java.awt.Font;
+import javax.swing.JComponent;
+
+/**
+ * This class uses standard Swing calls to handle screen, keyboard, and mouse
+ * I/O.
+ */
+public class SwingBackend extends GenericBackend {
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  The window will be 80x25 with font size 20 pts.
+     */
+    public SwingBackend() {
+        this(null, 80, 25, 20);
+    }
+
+    /**
+     * Public constructor.  The window will be 80x25 with font size 20 pts.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     */
+    public SwingBackend(final Object listener) {
+        this(listener, 80, 25, 20);
+    }
+
+    /**
+     * Public constructor will spawn a new JFrame with font size 20 pts.
+     *
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     */
+    public SwingBackend(final int windowWidth, final int windowHeight) {
+        this(null, windowWidth, windowHeight, 20);
+    }
+
+    /**
+     * Public constructor will spawn a new JFrame.
+     *
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points.  Good values to pick are: 16, 20,
+     * 22, and 24.
+     */
+    public SwingBackend(final int windowWidth, final int windowHeight,
+        final int fontSize) {
+
+        this(null, windowWidth, windowHeight, fontSize);
+    }
+
+    /**
+     * Public constructor will spawn a new JFrame.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points.  Good values to pick are: 16, 20,
+     * 22, and 24.
+     */
+    public SwingBackend(final Object listener, final int windowWidth,
+        final int windowHeight, final int fontSize) {
+
+        // Create a Swing backend using a JFrame
+        terminal = new SwingTerminal(windowWidth, windowHeight, fontSize,
+            listener);
+
+        // Hang onto the session info
+        this.sessionInfo = ((SwingTerminal) terminal).getSessionInfo();
+
+        // SwingTerminal is the screen too
+        screen = (SwingTerminal) terminal;
+    }
+
+    /**
+     * Public constructor will render onto a JComponent.
+     *
+     * @param component the Swing component to render to
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points.  Good values to pick are: 16, 20,
+     * 22, and 24.
+     */
+    public SwingBackend(final JComponent component, final Object listener,
+        final int windowWidth, final int windowHeight, final int fontSize) {
+
+        // Create a Swing backend using a JComponent
+        terminal = new SwingTerminal(component, windowWidth, windowHeight,
+            fontSize, listener);
+
+        // Hang onto the session info
+        this.sessionInfo = ((SwingTerminal) terminal).getSessionInfo();
+
+        // SwingTerminal is the screen too
+        screen = (SwingTerminal) terminal;
+    }
+
+    // ------------------------------------------------------------------------
+    // SwingBackend -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set to a new font, and resize the screen to match its dimensions.
+     *
+     * @param font the new font
+     */
+    public void setFont(final Font font) {
+        ((SwingTerminal) terminal).setFont(font);
+    }
+
+    /**
+     * Get the number of millis to wait before switching the blink from
+     * visible to invisible.
+     *
+     * @return the number of milli to wait before switching the blink from
+     * visible to invisible
+     */
+    public long getBlinkMillis() {
+        return ((SwingTerminal) terminal).getBlinkMillis();
+    }
+
+    /**
+     * Getter for the underlying Swing component.
+     *
+     * @return the SwingComponent
+     */
+    public SwingComponent getSwingComponent() {
+        return ((SwingTerminal) terminal).getSwingComponent();
+    }
+
+}
diff --git a/src/jexer/backend/SwingComponent.java b/src/jexer/backend/SwingComponent.java
new file mode 100644 (file)
index 0000000..df36333
--- /dev/null
@@ -0,0 +1,612 @@
+/*
+ * 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.backend;
+
+import java.awt.Color;
+import java.awt.Cursor;
+import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.Insets;
+import java.awt.Point;
+import java.awt.Toolkit;
+import java.awt.event.ComponentListener;
+import java.awt.event.KeyListener;
+import java.awt.event.MouseListener;
+import java.awt.event.MouseMotionListener;
+import java.awt.event.MouseWheelListener;
+import java.awt.event.WindowListener;
+import java.awt.image.BufferedImage;
+import java.awt.image.BufferStrategy;
+import java.io.IOException;
+import javax.imageio.ImageIO;
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.SwingUtilities;
+
+/**
+ * Wrapper for integrating with Swing, because JFrame and JComponent have
+ * separate hierarchies.
+ */
+class SwingComponent {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, use triple buffering when drawing to a JFrame.
+     */
+    public static boolean tripleBuffer = true;
+
+    /**
+     * The frame reference, if we are drawing to a JFrame.
+     */
+    private JFrame frame;
+
+    /**
+     * The component reference, if we are drawing to a JComponent.
+     */
+    private JComponent component;
+
+    /**
+     * An optional border in pixels to add.
+     */
+    private static final int BORDER = 1;
+
+    /**
+     * Adjustable Insets for this component.  This has the effect of adding a
+     * black border around the drawing area.
+     */
+    Insets adjustInsets = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Construct using a JFrame.
+     *
+     * @param frame the JFrame to draw to
+     */
+    public SwingComponent(final JFrame frame) {
+        this.frame = frame;
+        if (System.getProperty("os.name").startsWith("Linux")) {
+            // On my Linux dev system, a Swing frame draws its contents just
+            // a little off.  No idea why, but I've seen it on both Debian
+            // and Fedora with KDE.  These adjustments to the adjustments
+            // seem to center it OK in the frame.
+            adjustInsets = new Insets(BORDER + 5, BORDER,
+                BORDER - 3, BORDER + 2);
+        } else {
+            adjustInsets = new Insets(BORDER, BORDER, BORDER, BORDER);
+        }
+        setupFrame();
+    }
+
+    /**
+     * Construct using a JComponent.
+     *
+     * @param component the JComponent to draw to
+     */
+    public SwingComponent(final JComponent component) {
+        this.component = component;
+        adjustInsets = new Insets(BORDER, BORDER, BORDER, BORDER);
+        setupComponent();
+    }
+
+    // ------------------------------------------------------------------------
+    // SwingComponent ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the BufferStrategy object needed for triple-buffering.
+     *
+     * @return the BufferStrategy
+     * @throws IllegalArgumentException if this function is called when
+     * not rendering to a JFrame
+     */
+    public BufferStrategy getBufferStrategy() {
+        if (frame != null) {
+            return frame.getBufferStrategy();
+        } else {
+            throw new IllegalArgumentException("BufferStrategy not used " +
+                "for JComponent access");
+        }
+    }
+
+    /**
+     * Get the JFrame reference.
+     *
+     * @return the frame, or null if this is drawing to a JComponent
+     */
+    public JFrame getFrame() {
+        return frame;
+    }
+
+    /**
+     * Get the JComponent reference.
+     *
+     * @return the component, or null if this is drawing to a JFrame
+     */
+    public JComponent getComponent() {
+        return component;
+    }
+
+    /**
+     * Setup to render to an existing JComponent.
+     */
+    public void setupComponent() {
+        component.setBackground(Color.black);
+
+        if (System.getProperty("jexer.Swing.mouseImage") != null) {
+            component.setCursor(getMouseImage());
+        } else if (System.getProperty("jexer.Swing.mouseStyle") != null) {
+            component.setCursor(getMouseCursor());
+        } else if (System.getProperty("jexer.textMouse",
+                "true").equals("false")
+        ) {
+            // If the user has suppressed the text mouse, don't kill the X11
+            // mouse.
+            component.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+        } else {
+            // Kill the X11 cursor
+            // Transparent 16 x 16 pixel cursor image.
+            BufferedImage cursorImg = new BufferedImage(16, 16,
+                BufferedImage.TYPE_INT_ARGB);
+            // Create a new blank cursor.
+            Cursor blankCursor = Toolkit.getDefaultToolkit().createCustomCursor(
+                cursorImg, new Point(0, 0), "blank cursor");
+            component.setCursor(blankCursor);
+        }
+
+        // Be capable of seeing Tab / Shift-Tab
+        component.setFocusTraversalKeysEnabled(false);
+    }
+
+    /**
+     * Setup to render to an existing JFrame.
+     */
+    public void setupFrame() {
+        frame.setTitle("Jexer Application");
+        frame.setBackground(Color.black);
+        frame.pack();
+
+        if (System.getProperty("jexer.Swing.mouseImage") != null) {
+            frame.setCursor(getMouseImage());
+        } else if (System.getProperty("jexer.Swing.mouseStyle") != null) {
+            frame.setCursor(getMouseCursor());
+        } else if (System.getProperty("jexer.textMouse",
+                "true").equals("false")
+        ) {
+            // If the user has suppressed the text mouse, don't kill the X11
+            // mouse.
+            frame.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+        } else {
+            // Kill the X11 cursor
+            // Transparent 16 x 16 pixel cursor image.
+            BufferedImage cursorImg = new BufferedImage(16, 16,
+                BufferedImage.TYPE_INT_ARGB);
+            // Create a new blank cursor.
+            Cursor blankCursor = Toolkit.getDefaultToolkit().createCustomCursor(
+                cursorImg, new Point(0, 0), "blank cursor");
+            frame.setCursor(blankCursor);
+        }
+
+        // Be capable of seeing Tab / Shift-Tab
+        frame.setFocusTraversalKeysEnabled(false);
+
+        // Setup triple-buffering
+        if (tripleBuffer) {
+            frame.setIgnoreRepaint(true);
+            frame.createBufferStrategy(3);
+        }
+    }
+
+    /**
+     * Load an image named in jexer.Swing.mouseImage as the mouse cursor.
+     * The image must be on the classpath.
+     *
+     * @return the cursor
+     */
+    private Cursor getMouseImage() {
+        Cursor cursor = Cursor.getDefaultCursor();
+        String filename = System.getProperty("jexer.Swing.mouseImage");
+        assert (filename != null);
+
+        try {
+            ClassLoader loader = Thread.currentThread().
+                getContextClassLoader();
+
+            java.net.URL url = loader.getResource(filename);
+            if (url == null) {
+                // User named a file, but it's not on the classpath.  Bail
+                // out.
+                return cursor;
+            }
+
+            BufferedImage cursorImage = ImageIO.read(url);
+            java.awt.Dimension cursorSize = Toolkit.getDefaultToolkit().
+                getBestCursorSize(
+                        cursorImage.getWidth(), cursorImage.getHeight());
+
+            cursor = Toolkit.getDefaultToolkit().createCustomCursor(cursorImage,
+                new Point((int) Math.min(cursorImage.getWidth() / 2,
+                        cursorSize.getWidth() - 1),
+                    (int) Math.min(cursorImage.getHeight() / 2,
+                        cursorSize.getHeight() - 1)),
+                "custom cursor");
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+
+        return cursor;
+    }
+
+    /**
+     * Get the appropriate mouse cursor based on jexer.Swing.mouseStyle.
+     *
+     * @return the cursor
+     */
+    private Cursor getMouseCursor() {
+        Cursor cursor = Cursor.getDefaultCursor();
+        String style = System.getProperty("jexer.Swing.mouseStyle");
+        assert (style != null);
+
+        style = style.toLowerCase();
+
+        if (style.equals("none")) {
+            // Transparent 16 x 16 pixel cursor image.
+            BufferedImage cursorImg = new BufferedImage(16, 16,
+                BufferedImage.TYPE_INT_ARGB);
+            // Create a new blank cursor.
+            cursor = Toolkit.getDefaultToolkit().createCustomCursor(
+                cursorImg, new Point(0, 0), "blank cursor");
+        } else if (style.equals("default")) {
+            cursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR);
+        } else if (style.equals("hand")) {
+            cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR);
+        } else if (style.equals("text")) {
+            cursor = Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR);
+        } else if (style.equals("move")) {
+            cursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
+        } else if (style.equals("crosshair")) {
+            cursor = Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR);
+        }
+
+        return cursor;
+    }
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    public void setTitle(final String title) {
+        if (frame != null) {
+            frame.setTitle(title);
+        }
+    }
+
+    /**
+     * Paints this component.
+     *
+     * @param g the graphics context to use for painting
+     */
+    public void paint(Graphics g) {
+        if (frame != null) {
+            frame.paint(g);
+        } else {
+            component.paint(g);
+        }
+    }
+
+    /**
+     * Repaints this component.
+     */
+    public void repaint() {
+        if (frame != null) {
+            frame.repaint();
+        } else {
+            component.repaint();
+        }
+    }
+
+    /**
+     * Repaints the specified rectangle of this component.
+     *
+     * @param x the x coordinate
+     * @param y the y coordinate
+     * @param width the width
+     * @param height the height
+     */
+    public void repaint(int x, int y, int width, int height) {
+        if (frame != null) {
+            frame.repaint(x, y, width, height);
+        } else {
+            component.repaint(x, y, width, height);
+        }
+    }
+
+    /**
+     * If a border has been set on this component, returns the border's
+     * insets; otherwise calls super.getInsets.
+     *
+     * @return the value of the insets property
+     */
+    public Insets getInsets() {
+        Insets swingInsets = null;
+        if (frame != null) {
+            swingInsets = frame.getInsets();
+        } else {
+            swingInsets = component.getInsets();
+        }
+        Insets result = new Insets(swingInsets.top + adjustInsets.top,
+            swingInsets.left + adjustInsets.left,
+            swingInsets.bottom + adjustInsets.bottom,
+            swingInsets.right + adjustInsets.right);
+        return result;
+    }
+
+    /**
+     * Returns the current width of this component.
+     *
+     * @return the current width of this component
+     */
+    public int getWidth() {
+        if (frame != null) {
+            return frame.getWidth();
+        } else {
+            return component.getWidth();
+        }
+    }
+
+    /**
+     * Returns the current height of this component.
+     *
+     * @return the current height of this component
+     */
+    public int getHeight() {
+        if (frame != null) {
+            return frame.getHeight();
+        } else {
+            return component.getHeight();
+        }
+    }
+
+    /**
+     * Gets the font of this component.
+     *
+     * @return this component's font; if a font has not been set for this
+     * component, the font of its parent is returned
+     */
+    public Font getFont() {
+        if (frame != null) {
+            return frame.getFont();
+        } else {
+            return component.getFont();
+        }
+    }
+
+    /**
+     * Sets the font of this component.
+     *
+     * @param f the font to become this component's font; if this parameter
+     * is null then this component will inherit the font of its parent
+     */
+    public void setFont(final Font f) {
+        if (frame != null) {
+            frame.setFont(f);
+        } else {
+            component.setFont(f);
+        }
+    }
+
+    /**
+     * Shows or hides this Window depending on the value of parameter b.
+     *
+     * @param b if true, make visible, else make invisible
+     */
+    public void setVisible(final boolean b) {
+        if (frame != null) {
+            frame.setVisible(b);
+        } else {
+            component.setVisible(b);
+        }
+    }
+
+    /**
+     * Creates a graphics context for this component. This method will return
+     * null if this component is currently not displayable.
+     *
+     * @return a graphics context for this component, or null if it has none
+     */
+    public Graphics getGraphics() {
+        if (frame != null) {
+            return frame.getGraphics();
+        } else {
+            return component.getGraphics();
+        }
+    }
+
+    /**
+     * Releases all of the native screen resources used by this Window, its
+     * subcomponents, and all of its owned children. That is, the resources
+     * for these Components will be destroyed, any memory they consume will
+     * be returned to the OS, and they will be marked as undisplayable.
+     */
+    public void dispose() {
+        if (frame != null) {
+            frame.dispose();
+        } else {
+            component.getParent().remove(component);
+        }
+    }
+
+    /**
+     * Resize the component to match the font dimensions.
+     *
+     * @param width the new width in pixels
+     * @param height the new height in pixels
+     */
+    public void setDimensions(final int width, final int height) {
+        if (SwingUtilities.isEventDispatchThread()) {
+            // We are in the Swing thread and can safely set the size.
+
+            // Figure out the thickness of borders and use that to set the
+            // final size.
+            if (frame != null) {
+                Insets insets = getInsets();
+                frame.setSize(width + insets.left + insets.right,
+                    height + insets.top + insets.bottom);
+            } else {
+                Insets insets = getInsets();
+                component.setSize(width + insets.left + insets.right,
+                    height + insets.top + insets.bottom);
+            }
+            return;
+        }
+
+        SwingUtilities.invokeLater(new Runnable() {
+            public void run() {
+                // Figure out the thickness of borders and use that to set
+                // the final size.
+                if (frame != null) {
+                    Insets insets = getInsets();
+                    frame.setSize(width + insets.left + insets.right,
+                        height + insets.top + insets.bottom);
+                } else {
+                    Insets insets = getInsets();
+                    component.setSize(width + insets.left + insets.right,
+                        height + insets.top + insets.bottom);
+                }
+            }
+        });
+    }
+
+    /**
+     * Adds the specified component listener to receive component events from
+     * this component. If listener l is null, no exception is thrown and no
+     * action is performed.
+     *
+     * @param l the component listener
+     */
+    public void addComponentListener(ComponentListener l) {
+        if (frame != null) {
+            frame.addComponentListener(l);
+        } else {
+            component.addComponentListener(l);
+        }
+    }
+
+    /**
+     * Adds the specified key listener to receive key events from this
+     * component. If l is null, no exception is thrown and no action is
+     * performed.
+     *
+     * @param l the key listener.
+     */
+    public void addKeyListener(KeyListener l) {
+        if (frame != null) {
+            frame.addKeyListener(l);
+        } else {
+            component.addKeyListener(l);
+        }
+    }
+
+    /**
+     * Adds the specified mouse listener to receive mouse events from this
+     * component. If listener l is null, no exception is thrown and no action
+     * is performed.
+     *
+     * @param l the mouse listener
+     */
+    public void addMouseListener(MouseListener l) {
+        if (frame != null) {
+            frame.addMouseListener(l);
+        } else {
+            component.addMouseListener(l);
+        }
+    }
+
+    /**
+     * Adds the specified mouse motion listener to receive mouse motion
+     * events from this component. If listener l is null, no exception is
+     * thrown and no action is performed.
+     *
+     * @param l the mouse motion listener
+     */
+    public void addMouseMotionListener(MouseMotionListener l) {
+        if (frame != null) {
+            frame.addMouseMotionListener(l);
+        } else {
+            component.addMouseMotionListener(l);
+        }
+    }
+
+    /**
+     * Adds the specified mouse wheel listener to receive mouse wheel events
+     * from this component. Containers also receive mouse wheel events from
+     * sub-components.
+     *
+     * @param l the mouse wheel listener
+     */
+    public void addMouseWheelListener(MouseWheelListener l) {
+        if (frame != null) {
+            frame.addMouseWheelListener(l);
+        } else {
+            component.addMouseWheelListener(l);
+        }
+    }
+
+    /**
+     * Adds the specified window listener to receive window events from this
+     * window. If l is null, no exception is thrown and no action is
+     * performed.
+     *
+     * @param l the window listener
+     */
+    public void addWindowListener(WindowListener l) {
+        if (frame != null) {
+            frame.addWindowListener(l);
+        }
+    }
+
+    /**
+     * Requests that this Component get the input focus, if this Component's
+     * top-level ancestor is already the focused Window.
+     */
+    public void requestFocusInWindow() {
+        if (frame != null) {
+            frame.requestFocusInWindow();
+        } else {
+            component.requestFocusInWindow();
+        }
+    }
+
+}
diff --git a/src/jexer/backend/SwingSessionInfo.java b/src/jexer/backend/SwingSessionInfo.java
new file mode 100644 (file)
index 0000000..2f74d70
--- /dev/null
@@ -0,0 +1,226 @@
+/*
+ * 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.backend;
+
+import java.awt.Insets;
+
+/**
+ * SwingSessionInfo provides a session implementation with a callback into
+ * Swing to support queryWindowSize().  The username is blank, language is
+ * "en_US", with a 80x25 text window.
+ */
+public class SwingSessionInfo implements SessionInfo {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The Swing JFrame or JComponent.
+     */
+    private SwingComponent swing;
+
+    /**
+     * The width of a text cell in pixels.
+     */
+    private int textWidth = 10;
+
+    /**
+     * The height of a text cell in pixels.
+     */
+    private int textHeight = 10;
+
+    /**
+     * User name.
+     */
+    private String username = "";
+
+    /**
+     * Language.
+     */
+    private String language = "en_US";
+
+    /**
+     * Text window width.
+     */
+    private int windowWidth = 80;
+
+    /**
+     * Text window height.
+     */
+    private int windowHeight = 25;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param swing the Swing JFrame or JComponent
+     * @param textWidth the width of a cell in pixels
+     * @param textHeight the height of a cell in pixels
+     */
+    public SwingSessionInfo(final SwingComponent swing, final int textWidth,
+        final int textHeight) {
+
+        this.swing      = swing;
+        this.textWidth  = textWidth;
+        this.textHeight = textHeight;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param swing the Swing JFrame or JComponent
+     * @param textWidth the width of a cell in pixels
+     * @param textHeight the height of a cell in pixels
+     * @param width the number of columns
+     * @param height the number of rows
+     */
+    public SwingSessionInfo(final SwingComponent swing, final int textWidth,
+        final int textHeight, final int width, final int height) {
+
+        this.swing              = swing;
+        this.textWidth          = textWidth;
+        this.textHeight         = textHeight;
+        this.windowWidth        = width;
+        this.windowHeight       = height;
+    }
+
+    // ------------------------------------------------------------------------
+    // SessionInfo ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Username getter.
+     *
+     * @return the username
+     */
+    public String getUsername() {
+        return this.username;
+    }
+
+    /**
+     * Username setter.
+     *
+     * @param username the value
+     */
+    public void setUsername(final String username) {
+        this.username = username;
+    }
+
+    /**
+     * Language getter.
+     *
+     * @return the language
+     */
+    public String getLanguage() {
+        return this.language;
+    }
+
+    /**
+     * Language setter.
+     *
+     * @param language the value
+     */
+    public void setLanguage(final String language) {
+        this.language = language;
+    }
+
+    /**
+     * Text window width getter.
+     *
+     * @return the window width
+     */
+    public int getWindowWidth() {
+        return windowWidth;
+    }
+
+    /**
+     * Text window height getter.
+     *
+     * @return the window height
+     */
+    public int getWindowHeight() {
+        return windowHeight;
+    }
+
+    /**
+     * Re-query the text window size.
+     */
+    public void queryWindowSize() {
+        Insets insets = swing.getInsets();
+        int width = swing.getWidth() - insets.left - insets.right;
+        int height = swing.getHeight() - insets.top - insets.bottom;
+        // In theory, if Java reported pixel-perfect dimensions, the
+        // expressions above would precisely line up with the requested
+        // window size from SwingComponent.setDimensions().  In practice,
+        // there appears to be a small difference.  Add half a text cell in
+        // both directions before the division to hopefully reach the same
+        // result as setDimensions() was supposed to give us.
+        width += (textWidth / 2);
+        height += (textHeight / 2);
+        windowWidth = width / textWidth;
+        windowHeight = height / textHeight;
+
+        /*
+        System.err.printf("queryWindowSize(): frame %d %d window %d %d\n",
+            swing.getWidth(), swing.getHeight(),
+            windowWidth, windowHeight);
+        */
+    }
+
+    // ------------------------------------------------------------------------
+    // SwingSessionInfo -------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the dimensions of a single text cell.
+     *
+     * @param textWidth the width of a cell in pixels
+     * @param textHeight the height of a cell in pixels
+     */
+    public void setTextCellDimensions(final int textWidth,
+        final int textHeight) {
+
+        this.textWidth  = textWidth;
+        this.textHeight = textHeight;
+    }
+
+    /**
+     * Getter for the underlying Swing component.
+     *
+     * @return the SwingComponent
+     */
+    public SwingComponent getSwingComponent() {
+        return swing;
+    }
+
+}
diff --git a/src/jexer/backend/SwingTerminal.java b/src/jexer/backend/SwingTerminal.java
new file mode 100644 (file)
index 0000000..0727efc
--- /dev/null
@@ -0,0 +1,2431 @@
+/*
+ * 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.backend;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.Graphics;
+import java.awt.Insets;
+import java.awt.Rectangle;
+import java.awt.RenderingHints;
+import java.awt.Toolkit;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
+import java.awt.event.KeyEvent;
+import java.awt.event.KeyListener;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.event.MouseMotionListener;
+import java.awt.event.MouseWheelEvent;
+import java.awt.event.MouseWheelListener;
+import java.awt.event.WindowEvent;
+import java.awt.event.WindowListener;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.ImageIcon;
+import javax.swing.SwingUtilities;
+
+import jexer.TKeypress;
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.event.TCommandEvent;
+import jexer.event.TInputEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This Screen backend reads keystrokes and mouse events and draws to either
+ * a Java Swing JFrame (potentially triple-buffered) or a JComponent.
+ *
+ * This class is a bit of an inversion of typical GUI classes.  It performs
+ * all of the drawing logic from SwingTerminal (which is not a Swing class),
+ * and uses a SwingComponent wrapper class to call the JFrame or JComponent
+ * methods.
+ */
+public class SwingTerminal extends LogicalScreen
+                           implements TerminalReader,
+                                      ComponentListener, KeyListener,
+                                      MouseListener, MouseMotionListener,
+                                      MouseWheelListener, WindowListener {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The icon image location.
+     */
+    private static final String ICONFILE = "jexer_logo_128.png";
+
+    /**
+     * The terminus font resource filename.
+     */
+    public static final String FONTFILE = "terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf";
+
+    /**
+     * Cursor style to draw.
+     */
+    public enum CursorStyle {
+        /**
+         * Use an underscore for the cursor.
+         */
+        UNDERLINE,
+
+        /**
+         * Use a solid block for the cursor.
+         */
+        BLOCK,
+
+        /**
+         * Use an outlined block for the cursor.
+         */
+        OUTLINE,
+
+        /**
+         * Use a vertical bar for the cursor.
+         */
+        VERTICAL_BAR,
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // Colors to map DOS colors to AWT colors.
+    private static Color MYBLACK;
+    private static Color MYRED;
+    private static Color MYGREEN;
+    private static Color MYYELLOW;
+    private static Color MYBLUE;
+    private static Color MYMAGENTA;
+    private static Color MYCYAN;
+    private static Color MYWHITE;
+    private static Color MYBOLD_BLACK;
+    private static Color MYBOLD_RED;
+    private static Color MYBOLD_GREEN;
+    private static Color MYBOLD_YELLOW;
+    private static Color MYBOLD_BLUE;
+    private static Color MYBOLD_MAGENTA;
+    private static Color MYBOLD_CYAN;
+    private static Color MYBOLD_WHITE;
+
+    /**
+     * When true, all the MYBLACK, MYRED, etc. colors are set.
+     */
+    private static boolean dosColors = false;
+
+    /**
+     * The Swing component or frame to draw to.
+     */
+    private SwingComponent swing;
+
+    /**
+     * A cache of previously-rendered glyphs for blinking text, when it is
+     * not visible.
+     */
+    private Map<Cell, BufferedImage> glyphCacheBlink;
+
+    /**
+     * A cache of previously-rendered glyphs for non-blinking, or
+     * blinking-and-visible, text.
+     */
+    private Map<Cell, BufferedImage> glyphCache;
+
+    /**
+     * If true, we were successful at getting the font dimensions.
+     */
+    private boolean gotFontDimensions = false;
+
+    /**
+     * The currently selected font.
+     */
+    private Font font = null;
+
+    /**
+     * The currently selected font size in points.
+     */
+    private int fontSize = 16;
+
+    /**
+     * Width of a character cell in pixels.
+     */
+    private int textWidth = 16;
+
+    /**
+     * Height of a character cell in pixels.
+     */
+    private int textHeight = 20;
+
+    /**
+     * Width of a character cell in pixels, as reported by font.
+     */
+    private int fontTextWidth = 1;
+
+    /**
+     * Height of a character cell in pixels, as reported by font.
+     */
+    private int fontTextHeight = 1;
+
+    /**
+     * Descent of a character cell in pixels.
+     */
+    private int maxDescent = 0;
+
+    /**
+     * System-dependent Y adjustment for text in the character cell.
+     */
+    private int textAdjustY = 0;
+
+    /**
+     * System-dependent X adjustment for text in the character cell.
+     */
+    private int textAdjustX = 0;
+
+    /**
+     * System-dependent height adjustment for text in the character cell.
+     */
+    private int textAdjustHeight = 0;
+
+    /**
+     * System-dependent width adjustment for text in the character cell.
+     */
+    private int textAdjustWidth = 0;
+
+    /**
+     * Top pixel absolute location.
+     */
+    private int top = 30;
+
+    /**
+     * Left pixel absolute location.
+     */
+    private int left = 30;
+
+    /**
+     * The cursor style to draw.
+     */
+    private CursorStyle cursorStyle = CursorStyle.UNDERLINE;
+
+    /**
+     * The number of millis to wait before switching the blink from visible
+     * to invisible.  Set to 0 or negative to disable blinking.
+     */
+    private long blinkMillis = 500;
+
+    /**
+     * If true, the cursor should be visible right now based on the blink
+     * time.
+     */
+    private boolean cursorBlinkVisible = true;
+
+    /**
+     * The time that the blink last flipped from visible to invisible or
+     * from invisible to visible.
+     */
+    private long lastBlinkTime = 0;
+
+    /**
+     * The session information.
+     */
+    private SwingSessionInfo sessionInfo;
+
+    /**
+     * The listening object that run() wakes up on new input.
+     */
+    private Object listener;
+
+    /**
+     * The event queue, filled up by a thread reading on input.
+     */
+    private List<TInputEvent> eventQueue;
+
+    /**
+     * The last reported mouse X position.
+     */
+    private int oldMouseX = -1;
+
+    /**
+     * The last reported mouse Y position.
+     */
+    private int oldMouseY = -1;
+
+    /**
+     * true if mouse1 was down.  Used to report mouse1 on the release event.
+     */
+    private boolean mouse1 = false;
+
+    /**
+     * true if mouse2 was down.  Used to report mouse2 on the release event.
+     */
+    private boolean mouse2 = false;
+
+    /**
+     * true if mouse3 was down.  Used to report mouse3 on the release event.
+     */
+    private boolean mouse3 = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Static constructor.
+     */
+    static {
+        setDOSColors();
+    }
+
+    /**
+     * Public constructor creates a new JFrame to render to.
+     *
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points.  Good values to pick are: 16, 20,
+     * 22, and 24.
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     */
+    public SwingTerminal(final int windowWidth, final int windowHeight,
+        final int fontSize, final Object listener) {
+
+        this.fontSize = fontSize;
+
+        reloadOptions();
+
+        try {
+            SwingUtilities.invokeAndWait(new Runnable() {
+                public void run() {
+
+                    JFrame frame = new JFrame() {
+
+                        /**
+                         * Serializable version.
+                         */
+                        private static final long serialVersionUID = 1;
+
+                        /**
+                         * The code that performs the actual drawing.
+                         */
+                        public SwingTerminal screen = null;
+
+                        /*
+                         * Anonymous class initializer saves the screen
+                         * reference, so that paint() and the like call out
+                         * to SwingTerminal.
+                         */
+                        {
+                            this.screen = SwingTerminal.this;
+                        }
+
+                        /**
+                         * Update redraws the whole screen.
+                         *
+                         * @param gr the Swing Graphics context
+                         */
+                        @Override
+                        public void update(final Graphics gr) {
+                            // The default update clears the area.  Don't do
+                            // that, instead just paint it directly.
+                            paint(gr);
+                        }
+
+                        /**
+                         * Paint redraws the whole screen.
+                         *
+                         * @param gr the Swing Graphics context
+                         */
+                        @Override
+                        public void paint(final Graphics gr) {
+                            if (screen != null) {
+                                screen.paint(gr);
+                            }
+                        }
+                    };
+
+                    // Set icon
+                    ClassLoader loader = Thread.currentThread().
+                        getContextClassLoader();
+                    frame.setIconImage((new ImageIcon(loader.
+                                getResource(ICONFILE))).getImage());
+
+                    // Get the Swing component
+                    SwingTerminal.this.swing = new SwingComponent(frame);
+
+                    // Hang onto top and left for drawing.
+                    Insets insets = SwingTerminal.this.swing.getInsets();
+                    SwingTerminal.this.left = insets.left;
+                    SwingTerminal.this.top = insets.top;
+
+                    // Load the font so that we can set sessionInfo.
+                    setDefaultFont();
+
+                    // Get the default cols x rows and set component size
+                    // accordingly.
+                    SwingTerminal.this.sessionInfo =
+                        new SwingSessionInfo(SwingTerminal.this.swing,
+                            SwingTerminal.this.textWidth,
+                            SwingTerminal.this.textHeight,
+                            windowWidth, windowHeight);
+
+                    SwingTerminal.this.setDimensions(sessionInfo.
+                        getWindowWidth(), sessionInfo.getWindowHeight());
+
+                    SwingTerminal.this.resizeToScreen(true);
+                    SwingTerminal.this.swing.setVisible(true);
+                }
+            });
+        } catch (java.lang.reflect.InvocationTargetException e) {
+            e.printStackTrace();
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+
+        this.listener    = listener;
+        mouse1           = false;
+        mouse2           = false;
+        mouse3           = false;
+        eventQueue       = new ArrayList<TInputEvent>();
+
+        // Add listeners to Swing.
+        swing.addKeyListener(this);
+        swing.addWindowListener(this);
+        swing.addComponentListener(this);
+        swing.addMouseListener(this);
+        swing.addMouseMotionListener(this);
+        swing.addMouseWheelListener(this);
+    }
+
+    /**
+     * Public constructor renders to an existing JComponent.
+     *
+     * @param component the Swing component to render to
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
+     * @param fontSize the size in points.  Good values to pick are: 16, 20,
+     * 22, and 24.
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     */
+    public SwingTerminal(final JComponent component, final int windowWidth,
+        final int windowHeight, final int fontSize, final Object listener) {
+
+        this.fontSize = fontSize;
+
+        reloadOptions();
+
+        try {
+            SwingUtilities.invokeAndWait(new Runnable() {
+                public void run() {
+
+                    JComponent newComponent = new JComponent() {
+
+                        /**
+                         * Serializable version.
+                         */
+                        private static final long serialVersionUID = 1;
+
+                        /**
+                         * The code that performs the actual drawing.
+                         */
+                        public SwingTerminal screen = null;
+
+                        /*
+                         * Anonymous class initializer saves the screen
+                         * reference, so that paint() and the like call out
+                         * to SwingTerminal.
+                         */
+                        {
+                            this.screen = SwingTerminal.this;
+                        }
+
+                        /**
+                         * Update redraws the whole screen.
+                         *
+                         * @param gr the Swing Graphics context
+                         */
+                        @Override
+                        public void update(final Graphics gr) {
+                            // The default update clears the area.  Don't do
+                            // that, instead just paint it directly.
+                            paint(gr);
+                        }
+
+                        /**
+                         * Paint redraws the whole screen.
+                         *
+                         * @param gr the Swing Graphics context
+                         */
+                        @Override
+                        public void paint(final Graphics gr) {
+                            if (screen != null) {
+                                screen.paint(gr);
+                            }
+                        }
+                    };
+                    component.setLayout(new BorderLayout());
+                    component.add(newComponent);
+
+                    // Allow key events to be received
+                    component.setFocusable(true);
+
+                    // Get the Swing component
+                    SwingTerminal.this.swing = new SwingComponent(component);
+
+                    // Hang onto top and left for drawing.
+                    Insets insets = SwingTerminal.this.swing.getInsets();
+                    SwingTerminal.this.left = insets.left;
+                    SwingTerminal.this.top = insets.top;
+
+                    // Load the font so that we can set sessionInfo.
+                    setDefaultFont();
+
+                    // Get the default cols x rows and set component size
+                    // accordingly.
+                    SwingTerminal.this.sessionInfo =
+                        new SwingSessionInfo(SwingTerminal.this.swing,
+                            SwingTerminal.this.textWidth,
+                            SwingTerminal.this.textHeight);
+                }
+            });
+        } catch (java.lang.reflect.InvocationTargetException e) {
+            e.printStackTrace();
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+
+        this.listener    = listener;
+        mouse1           = false;
+        mouse2           = false;
+        mouse3           = false;
+        eventQueue       = new ArrayList<TInputEvent>();
+
+        // Add listeners to Swing.
+        swing.addKeyListener(this);
+        swing.addWindowListener(this);
+        swing.addComponentListener(this);
+        swing.addMouseListener(this);
+        swing.addMouseMotionListener(this);
+        swing.addMouseWheelListener(this);
+    }
+
+    // ------------------------------------------------------------------------
+    // LogicalScreen ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    @Override
+    public void setTitle(final String title) {
+        swing.setTitle(title);
+    }
+
+    /**
+     * Push the logical screen to the physical device.
+     */
+    @Override
+    public void flushPhysical() {
+        // See if it is time to flip the blink time.
+        long nowTime = System.currentTimeMillis();
+        if (nowTime >= blinkMillis + lastBlinkTime) {
+            lastBlinkTime = nowTime;
+            cursorBlinkVisible = !cursorBlinkVisible;
+            // System.err.println("New lastBlinkTime: " + lastBlinkTime);
+        }
+
+        if ((swing.getFrame() != null)
+            && (swing.getBufferStrategy() != null)
+        ) {
+            do {
+                do {
+                    drawToSwing();
+                } while (swing.getBufferStrategy().contentsRestored());
+
+                swing.getBufferStrategy().show();
+                Toolkit.getDefaultToolkit().sync();
+            } while (swing.getBufferStrategy().contentsLost());
+        } else {
+            // Non-triple-buffered, call drawToSwing() once
+            drawToSwing();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TerminalReader ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the backend
+     */
+    public boolean hasEvents() {
+        synchronized (eventQueue) {
+            return (eventQueue.size() > 0);
+        }
+    }
+
+    /**
+     * Return any events in the IO queue.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(final List<TInputEvent> queue) {
+        synchronized (eventQueue) {
+            if (eventQueue.size() > 0) {
+                synchronized (queue) {
+                    queue.addAll(eventQueue);
+                }
+                eventQueue.clear();
+            }
+        }
+    }
+
+    /**
+     * Restore terminal to normal state.
+     */
+    public void closeTerminal() {
+        shutdown();
+    }
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener) {
+        this.listener = listener;
+    }
+
+    /**
+     * Reload options from System properties.
+     */
+    public void reloadOptions() {
+        // Figure out my cursor style.
+        String cursorStyleString = System.getProperty(
+            "jexer.Swing.cursorStyle", "underline").toLowerCase();
+        if (cursorStyleString.equals("underline")) {
+            cursorStyle = CursorStyle.UNDERLINE;
+        } else if (cursorStyleString.equals("outline")) {
+            cursorStyle = CursorStyle.OUTLINE;
+        } else if (cursorStyleString.equals("block")) {
+            cursorStyle = CursorStyle.BLOCK;
+        } else if (cursorStyleString.equals("verticalbar")) {
+            cursorStyle = CursorStyle.VERTICAL_BAR;
+        }
+
+        // Pull the system property for triple buffering.
+        if (System.getProperty("jexer.Swing.tripleBuffer",
+                "true").equals("true")
+        ) {
+            SwingComponent.tripleBuffer = true;
+        } else {
+            SwingComponent.tripleBuffer = false;
+        }
+
+        // Set custom colors
+        setCustomSystemColors();
+    }
+
+    // ------------------------------------------------------------------------
+    // SwingTerminal ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the width of a character cell in pixels.
+     *
+     * @return the width in pixels of a character cell
+     */
+    public int getTextWidth() {
+        return textWidth;
+    }
+
+    /**
+     * Get the height of a character cell in pixels.
+     *
+     * @return the height in pixels of a character cell
+     */
+    public int getTextHeight() {
+        return textHeight;
+    }
+
+    /**
+     * Setup Swing colors to match DOS color palette.
+     */
+    private static void setDOSColors() {
+        if (dosColors) {
+            return;
+        }
+        MYBLACK         = new Color(0x00, 0x00, 0x00);
+        MYRED           = new Color(0xa8, 0x00, 0x00);
+        MYGREEN         = new Color(0x00, 0xa8, 0x00);
+        MYYELLOW        = new Color(0xa8, 0x54, 0x00);
+        MYBLUE          = new Color(0x00, 0x00, 0xa8);
+        MYMAGENTA       = new Color(0xa8, 0x00, 0xa8);
+        MYCYAN          = new Color(0x00, 0xa8, 0xa8);
+        MYWHITE         = new Color(0xa8, 0xa8, 0xa8);
+        MYBOLD_BLACK    = new Color(0x54, 0x54, 0x54);
+        MYBOLD_RED      = new Color(0xfc, 0x54, 0x54);
+        MYBOLD_GREEN    = new Color(0x54, 0xfc, 0x54);
+        MYBOLD_YELLOW   = new Color(0xfc, 0xfc, 0x54);
+        MYBOLD_BLUE     = new Color(0x54, 0x54, 0xfc);
+        MYBOLD_MAGENTA  = new Color(0xfc, 0x54, 0xfc);
+        MYBOLD_CYAN     = new Color(0x54, 0xfc, 0xfc);
+        MYBOLD_WHITE    = new Color(0xfc, 0xfc, 0xfc);
+
+        dosColors = true;
+    }
+
+    /**
+     * Setup Swing colors to match those provided in system properties.
+     */
+    private static void setCustomSystemColors() {
+        synchronized (SwingTerminal.class) {
+            MYBLACK   = getCustomColor("jexer.Swing.color0", MYBLACK);
+            MYRED     = getCustomColor("jexer.Swing.color1", MYRED);
+            MYGREEN   = getCustomColor("jexer.Swing.color2", MYGREEN);
+            MYYELLOW  = getCustomColor("jexer.Swing.color3", MYYELLOW);
+            MYBLUE    = getCustomColor("jexer.Swing.color4", MYBLUE);
+            MYMAGENTA = getCustomColor("jexer.Swing.color5", MYMAGENTA);
+            MYCYAN    = getCustomColor("jexer.Swing.color6", MYCYAN);
+            MYWHITE   = getCustomColor("jexer.Swing.color7", MYWHITE);
+            MYBOLD_BLACK   = getCustomColor("jexer.Swing.color8", MYBOLD_BLACK);
+            MYBOLD_RED     = getCustomColor("jexer.Swing.color9", MYBOLD_RED);
+            MYBOLD_GREEN   = getCustomColor("jexer.Swing.color10", MYBOLD_GREEN);
+            MYBOLD_YELLOW  = getCustomColor("jexer.Swing.color11", MYBOLD_YELLOW);
+            MYBOLD_BLUE    = getCustomColor("jexer.Swing.color12", MYBOLD_BLUE);
+            MYBOLD_MAGENTA = getCustomColor("jexer.Swing.color13", MYBOLD_MAGENTA);
+            MYBOLD_CYAN    = getCustomColor("jexer.Swing.color14", MYBOLD_CYAN);
+            MYBOLD_WHITE   = getCustomColor("jexer.Swing.color15", MYBOLD_WHITE);
+        }
+    }
+
+    /**
+     * Setup one Swing color to match the RGB value provided in system
+     * properties.
+     *
+     * @param key the system property key
+     * @param defaultColor the default color to return if key is not set, or
+     * incorrect
+     * @return a color from the RGB string, or defaultColor
+     */
+    private static Color getCustomColor(final String key,
+        final Color defaultColor) {
+
+        String rgb = System.getProperty(key);
+        if (rgb == null) {
+            return defaultColor;
+        }
+        if (rgb.startsWith("#")) {
+            rgb = rgb.substring(1);
+        }
+        int rgbInt = 0;
+        try {
+            rgbInt = Integer.parseInt(rgb, 16);
+        } catch (NumberFormatException e) {
+            return defaultColor;
+        }
+        Color color = new Color((rgbInt & 0xFF0000) >>> 16,
+            (rgbInt & 0x00FF00) >>> 8,
+            (rgbInt & 0x0000FF));
+
+        return color;
+    }
+
+    /**
+     * Get the number of millis to wait before switching the blink from
+     * visible to invisible.
+     *
+     * @return the number of milli to wait before switching the blink from
+     * visible to invisible
+     */
+    public long getBlinkMillis() {
+        return blinkMillis;
+    }
+
+    /**
+     * Get the current status of the blink flag.
+     *
+     * @return true if the cursor and blinking text should be visible
+     */
+    public boolean getCursorBlinkVisible() {
+        return cursorBlinkVisible;
+    }
+
+    /**
+     * Get the font size in points.
+     *
+     * @return font size in points
+     */
+    public int getFontSize() {
+        return fontSize;
+    }
+
+    /**
+     * Set the font size in points.
+     *
+     * @param fontSize font size in points
+     */
+    public void setFontSize(final int fontSize) {
+        this.fontSize = fontSize;
+        Font newFont = font.deriveFont((float) fontSize);
+        setFont(newFont);
+    }
+
+    /**
+     * Set to a new font, and resize the screen to match its dimensions.
+     *
+     * @param font the new font
+     */
+    public void setFont(final Font font) {
+        if (!SwingUtilities.isEventDispatchThread()) {
+            // Not in the Swing thread: force this inside the Swing thread.
+            try {
+                SwingUtilities.invokeAndWait(new Runnable() {
+                    public void run() {
+                        synchronized (this) {
+                            SwingTerminal.this.font = font;
+                            getFontDimensions();
+                            swing.setFont(font);
+                            glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+                            glyphCache = new HashMap<Cell, BufferedImage>();
+                            resizeToScreen(true);
+                        }
+                    }
+                });
+            } catch (InterruptedException e) {
+                e.printStackTrace();
+            } catch (java.lang.reflect.InvocationTargetException e) {
+                e.printStackTrace();
+            }
+        } else {
+            synchronized (this) {
+                SwingTerminal.this.font = font;
+                getFontDimensions();
+                swing.setFont(font);
+                glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+                glyphCache = new HashMap<Cell, BufferedImage>();
+                resizeToScreen(true);
+            }
+        }
+    }
+
+    /**
+     * Get the font this screen was last set to.
+     *
+     * @return the font
+     */
+    public Font getFont() {
+        return font;
+    }
+
+    /**
+     * Set the font to Terminus, the best all-around font for both CP437 and
+     * ISO8859-1.
+     */
+    public void setDefaultFont() {
+        try {
+            ClassLoader loader = Thread.currentThread().getContextClassLoader();
+            InputStream in = loader.getResourceAsStream(FONTFILE);
+            Font terminusRoot = Font.createFont(Font.TRUETYPE_FONT, in);
+            Font terminus = terminusRoot.deriveFont(Font.PLAIN, fontSize);
+            font = terminus;
+        } catch (java.awt.FontFormatException e) {
+            e.printStackTrace();
+            font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+        } catch (java.io.IOException e) {
+            e.printStackTrace();
+            font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+        }
+
+        setFont(font);
+    }
+
+    /**
+     * Get the X text adjustment.
+     *
+     * @return X text adjustment
+     */
+    public int getTextAdjustX() {
+        return textAdjustX;
+    }
+
+    /**
+     * Set the X text adjustment.
+     *
+     * @param textAdjustX the X text adjustment
+     */
+    public void setTextAdjustX(final int textAdjustX) {
+        synchronized (this) {
+            this.textAdjustX = textAdjustX;
+            glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+            glyphCache = new HashMap<Cell, BufferedImage>();
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Get the Y text adjustment.
+     *
+     * @return Y text adjustment
+     */
+    public int getTextAdjustY() {
+        return textAdjustY;
+    }
+
+    /**
+     * Set the Y text adjustment.
+     *
+     * @param textAdjustY the Y text adjustment
+     */
+    public void setTextAdjustY(final int textAdjustY) {
+        synchronized (this) {
+            this.textAdjustY = textAdjustY;
+            glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+            glyphCache = new HashMap<Cell, BufferedImage>();
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Get the height text adjustment.
+     *
+     * @return height text adjustment
+     */
+    public int getTextAdjustHeight() {
+        return textAdjustHeight;
+    }
+
+    /**
+     * Set the height text adjustment.
+     *
+     * @param textAdjustHeight the height text adjustment
+     */
+    public void setTextAdjustHeight(final int textAdjustHeight) {
+        synchronized (this) {
+            this.textAdjustHeight = textAdjustHeight;
+            textHeight = fontTextHeight + textAdjustHeight;
+            glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+            glyphCache = new HashMap<Cell, BufferedImage>();
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Get the width text adjustment.
+     *
+     * @return width text adjustment
+     */
+    public int getTextAdjustWidth() {
+        return textAdjustWidth;
+    }
+
+    /**
+     * Set the width text adjustment.
+     *
+     * @param textAdjustWidth the width text adjustment
+     */
+    public void setTextAdjustWidth(final int textAdjustWidth) {
+        synchronized (this) {
+            this.textAdjustWidth = textAdjustWidth;
+            textWidth = fontTextWidth + textAdjustWidth;
+            glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+            glyphCache = new HashMap<Cell, BufferedImage>();
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Convert a CellAttributes foreground color to an Swing Color.
+     *
+     * @param attr the text attributes
+     * @return the Swing Color
+     */
+    public static Color attrToForegroundColor(final CellAttributes attr) {
+        int rgb = attr.getForeColorRGB();
+        if (rgb >= 0) {
+            int red     = (rgb >> 16) & 0xFF;
+            int green   = (rgb >>  8) & 0xFF;
+            int blue    =  rgb        & 0xFF;
+
+            return new Color(red, green, blue);
+        }
+
+        if (attr.isBold()) {
+            if (attr.getForeColor().equals(jexer.bits.Color.BLACK)) {
+                return MYBOLD_BLACK;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.RED)) {
+                return MYBOLD_RED;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.BLUE)) {
+                return MYBOLD_BLUE;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.GREEN)) {
+                return MYBOLD_GREEN;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.YELLOW)) {
+                return MYBOLD_YELLOW;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.CYAN)) {
+                return MYBOLD_CYAN;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.MAGENTA)) {
+                return MYBOLD_MAGENTA;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.WHITE)) {
+                return MYBOLD_WHITE;
+            }
+        } else {
+            if (attr.getForeColor().equals(jexer.bits.Color.BLACK)) {
+                return MYBLACK;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.RED)) {
+                return MYRED;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.BLUE)) {
+                return MYBLUE;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.GREEN)) {
+                return MYGREEN;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.YELLOW)) {
+                return MYYELLOW;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.CYAN)) {
+                return MYCYAN;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.MAGENTA)) {
+                return MYMAGENTA;
+            } else if (attr.getForeColor().equals(jexer.bits.Color.WHITE)) {
+                return MYWHITE;
+            }
+        }
+        throw new IllegalArgumentException("Invalid color: " +
+            attr.getForeColor().getValue());
+    }
+
+    /**
+     * Convert a CellAttributes background color to an Swing Color.
+     *
+     * @param attr the text attributes
+     * @return the Swing Color
+     */
+    public static Color attrToBackgroundColor(final CellAttributes attr) {
+        int rgb = attr.getBackColorRGB();
+        if (rgb >= 0) {
+            int red     = (rgb >> 16) & 0xFF;
+            int green   = (rgb >>  8) & 0xFF;
+            int blue    =  rgb        & 0xFF;
+
+            return new Color(red, green, blue);
+        }
+
+        if (attr.getBackColor().equals(jexer.bits.Color.BLACK)) {
+            return MYBLACK;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.RED)) {
+            return MYRED;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.BLUE)) {
+            return MYBLUE;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.GREEN)) {
+            return MYGREEN;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.YELLOW)) {
+            return MYYELLOW;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.CYAN)) {
+            return MYCYAN;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.MAGENTA)) {
+            return MYMAGENTA;
+        } else if (attr.getBackColor().equals(jexer.bits.Color.WHITE)) {
+            return MYWHITE;
+        }
+        throw new IllegalArgumentException("Invalid color: " +
+            attr.getBackColor().getValue());
+    }
+
+    /**
+     * Figure out what textAdjustX, textAdjustY, textAdjustHeight, and
+     * textAdjustWidth should be, based on the location of a vertical bar and
+     * a horizontal bar.
+     */
+    private void getFontAdjustments() {
+        BufferedImage image = null;
+
+        // What SHOULD happen is that the topmost/leftmost white pixel is at
+        // position (gr2x, gr2y).  But it might also be off by a pixel in
+        // either direction.
+
+        Graphics2D gr2 = null;
+        int gr2x = 3;
+        int gr2y = 3;
+        image = new BufferedImage(fontTextWidth * 2, fontTextHeight * 2,
+            BufferedImage.TYPE_INT_ARGB);
+
+        gr2 = image.createGraphics();
+        gr2.setFont(swing.getFont());
+        gr2.setColor(java.awt.Color.BLACK);
+        gr2.fillRect(0, 0, fontTextWidth * 2, fontTextHeight * 2);
+        gr2.setColor(java.awt.Color.WHITE);
+        char [] chars = new char[1];
+        chars[0] = jexer.bits.GraphicsChars.SINGLE_BAR;
+        gr2.drawChars(chars, 0, 1, gr2x, gr2y + fontTextHeight - maxDescent);
+        chars[0] = jexer.bits.GraphicsChars.VERTICAL_BAR;
+        gr2.drawChars(chars, 0, 1, gr2x, gr2y + fontTextHeight - maxDescent);
+        gr2.dispose();
+
+        int top = fontTextHeight * 2;
+        int bottom = -1;
+        int left = fontTextWidth * 2;
+        int right = -1;
+        textAdjustX = 0;
+        textAdjustY = 0;
+        textAdjustHeight = 0;
+        textAdjustWidth = 0;
+
+        for (int x = 0; x < fontTextWidth * 2; x++) {
+            for (int y = 0; y < fontTextHeight * 2; y++) {
+
+                /*
+                System.err.println("H X: " + x + " Y: " + y + " " +
+                    image.getRGB(x, y));
+                */
+
+                if ((image.getRGB(x, y) & 0xFFFFFF) != 0) {
+                    // Pixel is present.
+                    if (y < top) {
+                        top = y;
+                    }
+                    if (y > bottom) {
+                        bottom = y;
+                    }
+                    if (x < left) {
+                        left = x;
+                    }
+                    if (x > right) {
+                        right = x;
+                    }
+                }
+            }
+        }
+        if (left < right) {
+            textAdjustX = (gr2x - left);
+            textAdjustWidth = fontTextWidth - (right - left + 1);
+        }
+        if (top < bottom) {
+            textAdjustY = (gr2y - top);
+            textAdjustHeight = fontTextHeight - (bottom - top + 1);
+        }
+        // System.err.println("top " + top + " bottom " + bottom);
+        // System.err.println("left " + left + " right " + right);
+
+        // Special case: do not believe fonts that claim to be wider than
+        // they are tall.
+        if (fontTextWidth >= fontTextHeight) {
+            textAdjustX = 0;
+            textAdjustWidth = 0;
+            fontTextWidth = fontTextHeight / 2;
+        }
+    }
+
+    /**
+     * Figure out my font dimensions.  This code path works OK for the JFrame
+     * case, and can be called immediately after JFrame creation.
+     */
+    private void getFontDimensions() {
+        swing.setFont(font);
+        Graphics gr = swing.getGraphics();
+        if (gr == null) {
+            return;
+        }
+        getFontDimensions(gr);
+    }
+
+    /**
+     * Figure out my font dimensions.  This code path is needed to lazy-load
+     * the information inside paint().
+     *
+     * @param gr Graphics object to use
+     */
+    private void getFontDimensions(final Graphics gr) {
+        swing.setFont(font);
+        FontMetrics fm = gr.getFontMetrics();
+        maxDescent = fm.getMaxDescent();
+        Rectangle2D bounds = fm.getMaxCharBounds(gr);
+        int leading = fm.getLeading();
+        fontTextWidth = (int)Math.round(bounds.getWidth());
+        // fontTextHeight = (int)Math.round(bounds.getHeight()) - maxDescent;
+
+        // This produces the same number, but works better for ugly
+        // monospace.
+        fontTextHeight = fm.getMaxAscent() + maxDescent - leading;
+
+        getFontAdjustments();
+        textHeight = fontTextHeight + textAdjustHeight;
+        textWidth = fontTextWidth + textAdjustWidth;
+
+        if (sessionInfo != null) {
+            sessionInfo.setTextCellDimensions(textWidth, textHeight);
+        }
+        gotFontDimensions = true;
+    }
+
+    /**
+     * Resize the physical screen to match the logical screen dimensions.
+     *
+     * @param resizeComponent if true, resize the Swing component
+     */
+    private void resizeToScreen(final boolean resizeComponent) {
+        if (resizeComponent) {
+            swing.setDimensions(textWidth * width, textHeight * height);
+        }
+        clearPhysical();
+    }
+
+    /**
+     * Resize the physical screen to match the logical screen dimensions.
+     */
+    @Override
+    public void resizeToScreen() {
+        resizeToScreen(false);
+    }
+
+    /**
+     * Draw one cell's image to the screen.
+     *
+     * @param gr the Swing Graphics context
+     * @param cell the Cell to draw
+     * @param xPixel the x-coordinate to render to.  0 means the
+     * left-most pixel column.
+     * @param yPixel the y-coordinate to render to.  0 means the top-most
+     * pixel row.
+     */
+    private void drawImage(final Graphics gr, final Cell cell,
+        final int xPixel, final int yPixel) {
+
+        /*
+        System.err.println("drawImage(): " + xPixel + " " + yPixel +
+            " " + cell);
+        */
+
+        // Draw the background rectangle, then the foreground character.
+        assert (cell.isImage());
+
+        // Enable anti-aliasing
+        if (gr instanceof Graphics2D) {
+            ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                RenderingHints.VALUE_ANTIALIAS_ON);
+            ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                RenderingHints.VALUE_RENDER_QUALITY);
+        }
+
+        gr.setColor(cell.getBackground());
+        gr.fillRect(xPixel, yPixel, textWidth, textHeight);
+
+        BufferedImage image = cell.getImage();
+        if (image != null) {
+            if (swing.getFrame() != null) {
+                gr.drawImage(image, xPixel, yPixel, getTextWidth(),
+                    getTextHeight(), swing.getFrame());
+            } else {
+                gr.drawImage(image, xPixel, yPixel,  getTextWidth(),
+                    getTextHeight(),swing.getComponent());
+            }
+            return;
+        }
+    }
+
+    /**
+     * Draw one glyph to the screen.
+     *
+     * @param gr the Swing Graphics context
+     * @param cell the Cell to draw
+     * @param xPixel the x-coordinate to render to.  0 means the
+     * left-most pixel column.
+     * @param yPixel the y-coordinate to render to.  0 means the top-most
+     * pixel row.
+     */
+    private void drawGlyph(final Graphics gr, final Cell cell,
+        final int xPixel, final int yPixel) {
+
+        /*
+        System.err.println("drawGlyph(): " + xPixel + " " + yPixel +
+            " " + cell);
+         */
+
+        BufferedImage image = null;
+        if (cell.isBlink() && !cursorBlinkVisible) {
+            image = glyphCacheBlink.get(cell);
+        } else {
+            image = glyphCache.get(cell);
+        }
+        if (image != null) {
+            if (swing.getFrame() != null) {
+                gr.drawImage(image, xPixel, yPixel, swing.getFrame());
+            } else {
+                gr.drawImage(image, xPixel, yPixel, swing.getComponent());
+            }
+            return;
+        }
+
+        // Generate glyph and draw it.
+        Graphics2D gr2 = null;
+        int gr2x = xPixel;
+        int gr2y = yPixel;
+        if ((SwingComponent.tripleBuffer) && (swing.getFrame() != null)) {
+            image = new BufferedImage(textWidth, textHeight,
+                BufferedImage.TYPE_INT_ARGB);
+            gr2 = image.createGraphics();
+            gr2.setFont(swing.getFont());
+            gr2x = 0;
+            gr2y = 0;
+        } else {
+            gr2 = (Graphics2D) gr;
+        }
+
+        Cell cellColor = new Cell(cell);
+
+        // Check for reverse
+        if (cell.isReverse()) {
+            cellColor.setForeColor(cell.getBackColor());
+            cellColor.setBackColor(cell.getForeColor());
+        }
+
+        // Enable anti-aliasing
+        if ((gr instanceof Graphics2D) && (swing.getFrame() != null)) {
+            // Anti-aliasing on JComponent makes the hash character disappear
+            // for Terminus font, and also kills performance.  Only enable it
+            // for JFrame.
+            ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                RenderingHints.VALUE_ANTIALIAS_ON);
+            ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                RenderingHints.VALUE_RENDER_QUALITY);
+        }
+
+        // Draw the background rectangle, then the foreground character.
+        gr2.setColor(attrToBackgroundColor(cellColor));
+        gr2.fillRect(gr2x, gr2y, textWidth, textHeight);
+
+        // Handle blink and underline
+        if (!cell.isBlink()
+            || (cell.isBlink() && cursorBlinkVisible)
+        ) {
+            gr2.setColor(attrToForegroundColor(cellColor));
+            char [] chars = Character.toChars(cell.getChar());
+            gr2.drawChars(chars, 0, chars.length, gr2x + textAdjustX,
+                gr2y + textHeight - maxDescent + textAdjustY);
+
+            if (cell.isUnderline()) {
+                gr2.fillRect(gr2x, gr2y + textHeight - 2, textWidth, 2);
+            }
+        }
+
+        if ((SwingComponent.tripleBuffer) && (swing.getFrame() != null)) {
+            gr2.dispose();
+
+            // We need a new key that will not be mutated by
+            // invertCell().
+            Cell key = new Cell(cell);
+            if (cell.isBlink() && !cursorBlinkVisible) {
+                glyphCacheBlink.put(key, image);
+            } else {
+                glyphCache.put(key, image);
+            }
+
+            if (swing.getFrame() != null) {
+                gr.drawImage(image, xPixel, yPixel, swing.getFrame());
+            } else {
+                gr.drawImage(image, xPixel, yPixel, swing.getComponent());
+            }
+        }
+
+    }
+
+    /**
+     * Check if the cursor is visible, and if so draw it.
+     *
+     * @param gr the Swing Graphics context
+     */
+    private void drawCursor(final Graphics gr) {
+
+        if (cursorVisible
+            && (cursorY >= 0)
+            && (cursorX >= 0)
+            && (cursorY <= height - 1)
+            && (cursorX <= width - 1)
+            && cursorBlinkVisible
+        ) {
+            int xPixel = cursorX * textWidth + left;
+            int yPixel = cursorY * textHeight + top;
+            Cell lCell = logical[cursorX][cursorY];
+            int cursorWidth = textWidth;
+            switch (lCell.getWidth()) {
+            case SINGLE:
+                // NOP
+                break;
+            case LEFT:
+                cursorWidth *= 2;
+                break;
+            case RIGHT:
+                cursorWidth *= 2;
+                xPixel -= textWidth;
+                break;
+            }
+            gr.setColor(attrToForegroundColor(lCell));
+            switch (cursorStyle) {
+            default:
+                // Fall through...
+            case UNDERLINE:
+                gr.fillRect(xPixel, yPixel + textHeight - 2, cursorWidth, 2);
+                break;
+            case BLOCK:
+                gr.fillRect(xPixel, yPixel, cursorWidth, textHeight);
+                break;
+            case OUTLINE:
+                gr.drawRect(xPixel, yPixel, cursorWidth - 1, textHeight - 1);
+                break;
+            case VERTICAL_BAR:
+                gr.fillRect(xPixel, yPixel, 2, textHeight);
+                break;
+            }
+        }
+    }
+
+    /**
+     * Reset the blink timer.
+     */
+    private void resetBlinkTimer() {
+        lastBlinkTime = System.currentTimeMillis();
+        cursorBlinkVisible = true;
+    }
+
+    /**
+     * Paint redraws the whole screen.
+     *
+     * @param gr the Swing Graphics context
+     */
+    public void paint(final Graphics gr) {
+
+        if (gotFontDimensions == false) {
+            // Lazy-load the text width/height
+            getFontDimensions(gr);
+            /*
+            System.err.println("textWidth " + textWidth +
+                " textHeight " + textHeight);
+            System.err.println("FONT: " + swing.getFont() + " font " + font);
+             */
+        }
+
+        if ((swing.getFrame() != null)
+            && (swing.getBufferStrategy() != null)
+            && (SwingUtilities.isEventDispatchThread())
+        ) {
+            // System.err.println("paint(), skip first paint on swing thread");
+            return;
+        }
+
+        int xCellMin = 0;
+        int xCellMax = width;
+        int yCellMin = 0;
+        int yCellMax = height;
+
+        Rectangle bounds = gr.getClipBounds();
+        if (bounds != null) {
+            // Only update what is in the bounds
+            xCellMin = textColumn(bounds.x);
+            xCellMax = textColumn(bounds.x + bounds.width) + 1;
+            if (xCellMax > width) {
+                xCellMax = width;
+            }
+            if (xCellMin >= xCellMax) {
+                xCellMin = xCellMax - 2;
+            }
+            if (xCellMin < 0) {
+                xCellMin = 0;
+            }
+            yCellMin = textRow(bounds.y);
+            yCellMax = textRow(bounds.y + bounds.height) + 1;
+            if (yCellMax > height) {
+                yCellMax = height;
+            }
+            if (yCellMin >= yCellMax) {
+                yCellMin = yCellMax - 2;
+            }
+            if (yCellMin < 0) {
+                yCellMin = 0;
+            }
+        } else {
+            // We need a total repaint
+            reallyCleared = true;
+        }
+
+        // Prevent updates to the screen's data from the TApplication
+        // threads.
+        synchronized (this) {
+
+            /*
+            System.err.printf("bounds %s X %d %d Y %d %d\n",
+                 bounds, xCellMin, xCellMax, yCellMin, yCellMax);
+             */
+
+            for (int y = yCellMin; y < yCellMax; y++) {
+                for (int x = xCellMin; x < xCellMax; x++) {
+
+                    int xPixel = x * textWidth + left;
+                    int yPixel = y * textHeight + top;
+
+                    Cell lCell = logical[x][y];
+                    Cell pCell = physical[x][y];
+
+                    if (!lCell.equals(pCell)
+                        || lCell.isBlink()
+                        || reallyCleared
+                        || (swing.getFrame() == null)) {
+
+                        if (lCell.isImage()) {
+                            drawImage(gr, lCell, xPixel, yPixel);
+                        } else {
+                            drawGlyph(gr, lCell, xPixel, yPixel);
+                        }
+
+                        // Physical is always updated
+                        physical[x][y].setTo(lCell);
+                    }
+                }
+            }
+            drawCursor(gr);
+
+            reallyCleared = false;
+        } // synchronized (this)
+    }
+
+    /**
+     * Restore terminal to normal state.
+     */
+    public void shutdown() {
+        swing.dispose();
+    }
+
+    /**
+     * Push the logical screen to the physical device.
+     */
+    private void drawToSwing() {
+
+        /*
+        System.err.printf("drawToSwing(): reallyCleared %s dirty %s\n",
+            reallyCleared, dirty);
+        */
+
+        // If reallyCleared is set, we have to draw everything.
+        if ((swing.getFrame() != null)
+            && (swing.getBufferStrategy() != null)
+            && (reallyCleared == true)
+        ) {
+            // Triple-buffering: we have to redraw everything on this thread.
+            Graphics gr = swing.getBufferStrategy().getDrawGraphics();
+            swing.paint(gr);
+            gr.dispose();
+            swing.getBufferStrategy().show();
+            Toolkit.getDefaultToolkit().sync();
+            return;
+        } else if (((swing.getFrame() != null)
+                && (swing.getBufferStrategy() == null))
+            || (reallyCleared == true)
+        ) {
+            // Repaint everything on the Swing thread.
+            // System.err.println("REPAINT ALL");
+            swing.repaint();
+            return;
+        }
+
+        if ((swing.getFrame() != null) && (swing.getBufferStrategy() != null)) {
+            Graphics gr = swing.getBufferStrategy().getDrawGraphics();
+
+            synchronized (this) {
+                for (int y = 0; y < height; y++) {
+                    for (int x = 0; x < width; x++) {
+                        Cell lCell = logical[x][y];
+                        Cell pCell = physical[x][y];
+
+                        int xPixel = x * textWidth + left;
+                        int yPixel = y * textHeight + top;
+
+                        if (!lCell.equals(pCell)
+                            || ((x == cursorX)
+                                && (y == cursorY)
+                                && cursorVisible)
+                            || (lCell.isBlink())
+                        ) {
+                            if (lCell.isImage()) {
+                                drawImage(gr, lCell, xPixel, yPixel);
+                            } else {
+                                drawGlyph(gr, lCell, xPixel, yPixel);
+                            }
+                            physical[x][y].setTo(lCell);
+                        }
+                    }
+                }
+                drawCursor(gr);
+            } // synchronized (this)
+
+            gr.dispose();
+            swing.getBufferStrategy().show();
+            Toolkit.getDefaultToolkit().sync();
+            return;
+        }
+
+        // Swing thread version: request a repaint, but limit it to the area
+        // that has changed.
+
+        // Find the minimum-size damaged region.
+        int xMin = swing.getWidth();
+        int xMax = 0;
+        int yMin = swing.getHeight();
+        int yMax = 0;
+
+        synchronized (this) {
+            for (int y = 0; y < height; y++) {
+                for (int x = 0; x < width; x++) {
+                    Cell lCell = logical[x][y];
+                    Cell pCell = physical[x][y];
+
+                    int xPixel = x * textWidth + left;
+                    int yPixel = y * textHeight + top;
+
+                    if (!lCell.equals(pCell)
+                        || ((x == cursorX)
+                            && (y == cursorY)
+                            && cursorVisible)
+                        || lCell.isBlink()
+                    ) {
+                        if (xPixel < xMin) {
+                            xMin = xPixel;
+                        }
+                        if (xPixel + textWidth > xMax) {
+                            xMax = xPixel + textWidth;
+                        }
+                        if (yPixel < yMin) {
+                            yMin = yPixel;
+                        }
+                        if (yPixel + textHeight > yMax) {
+                            yMax = yPixel + textHeight;
+                        }
+                    }
+                }
+            }
+        }
+        if (xMin + textWidth >= xMax) {
+            xMax += textWidth;
+        }
+        if (yMin + textHeight >= yMax) {
+            yMax += textHeight;
+        }
+
+        // Repaint the desired area
+        /*
+        System.err.printf("REPAINT X %d %d Y %d %d\n", xMin, xMax,
+            yMin, yMax);
+        */
+
+        if ((swing.getFrame() != null) && (swing.getBufferStrategy() != null)) {
+            // This path should never be taken, but is left here for
+            // completeness.
+            Graphics gr = swing.getBufferStrategy().getDrawGraphics();
+            Rectangle bounds = new Rectangle(xMin, yMin, xMax - xMin,
+                yMax - yMin);
+            gr.setClip(bounds);
+            swing.paint(gr);
+            gr.dispose();
+            swing.getBufferStrategy().show();
+            Toolkit.getDefaultToolkit().sync();
+        } else {
+            // Repaint on the Swing thread.
+            swing.repaint(xMin, yMin, xMax - xMin, yMax - yMin);
+        }
+    }
+
+    /**
+     * Convert pixel column position to text cell column position.
+     *
+     * @param x pixel column position
+     * @return text cell column position
+     */
+    public int textColumn(final int x) {
+        int column = ((x - left) / textWidth);
+        if (column < 0) {
+            column = 0;
+        }
+        if (column > width - 1) {
+            column = width - 1;
+        }
+        return column;
+    }
+
+    /**
+     * Convert pixel row position to text cell row position.
+     *
+     * @param y pixel row position
+     * @return text cell row position
+     */
+    public int textRow(final int y) {
+        int row = ((y - top) / textHeight);
+        if (row < 0) {
+            row = 0;
+        }
+        if (row > height - 1) {
+            row = height - 1;
+        }
+        return row;
+    }
+
+    /**
+     * Getter for sessionInfo.
+     *
+     * @return the SessionInfo
+     */
+    public SessionInfo getSessionInfo() {
+        return sessionInfo;
+    }
+
+    /**
+     * Getter for the underlying Swing component.
+     *
+     * @return the SwingComponent
+     */
+    public SwingComponent getSwingComponent() {
+        return swing;
+    }
+
+    // ------------------------------------------------------------------------
+    // KeyListener ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass Swing keystrokes into the event queue.
+     *
+     * @param key keystroke received
+     */
+    public void keyReleased(final KeyEvent key) {
+        // Ignore release events
+    }
+
+    /**
+     * Pass Swing keystrokes into the event queue.
+     *
+     * @param key keystroke received
+     */
+    public void keyTyped(final KeyEvent key) {
+        // Ignore typed events
+    }
+
+    /**
+     * Pass Swing keystrokes into the event queue.
+     *
+     * @param key keystroke received
+     */
+    public void keyPressed(final KeyEvent key) {
+        boolean alt = false;
+        boolean shift = false;
+        boolean ctrl = false;
+        char ch = ' ';
+        boolean isKey = false;
+        if (key.isActionKey()) {
+            isKey = true;
+        } else {
+            ch = key.getKeyChar();
+        }
+        // Both meta and alt count as alt, thanks to Mac using alt for
+        // "symbols" so meta ("command") is the only other modifier left.
+        alt = key.isAltDown() | key.isMetaDown();
+        ctrl = key.isControlDown();
+        shift = key.isShiftDown();
+
+        /*
+        System.err.printf("Swing Key: %s\n", key);
+        System.err.printf("   isKey: %s\n", isKey);
+        System.err.printf("   meta: %s\n", key.isMetaDown());
+        System.err.printf("   alt: %s\n", alt);
+        System.err.printf("   ctrl: %s\n", ctrl);
+        System.err.printf("   shift: %s\n", shift);
+        System.err.printf("   ch: %s\n", ch);
+        */
+
+        // Special case: not return the bare modifier presses
+        switch (key.getKeyCode()) {
+        case KeyEvent.VK_ALT:
+            return;
+        case KeyEvent.VK_ALT_GRAPH:
+            return;
+        case KeyEvent.VK_CONTROL:
+            return;
+        case KeyEvent.VK_SHIFT:
+            return;
+        case KeyEvent.VK_META:
+            return;
+        default:
+            break;
+        }
+
+        TKeypress keypress = null;
+        if (isKey) {
+            switch (key.getKeyCode()) {
+            case KeyEvent.VK_F1:
+                keypress = new TKeypress(true, TKeypress.F1, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F2:
+                keypress = new TKeypress(true, TKeypress.F2, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F3:
+                keypress = new TKeypress(true, TKeypress.F3, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F4:
+                keypress = new TKeypress(true, TKeypress.F4, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F5:
+                keypress = new TKeypress(true, TKeypress.F5, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F6:
+                keypress = new TKeypress(true, TKeypress.F6, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F7:
+                keypress = new TKeypress(true, TKeypress.F7, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F8:
+                keypress = new TKeypress(true, TKeypress.F8, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F9:
+                keypress = new TKeypress(true, TKeypress.F9, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F10:
+                keypress = new TKeypress(true, TKeypress.F10, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F11:
+                keypress = new TKeypress(true, TKeypress.F11, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_F12:
+                keypress = new TKeypress(true, TKeypress.F12, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_HOME:
+                keypress = new TKeypress(true, TKeypress.HOME, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_END:
+                keypress = new TKeypress(true, TKeypress.END, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_PAGE_UP:
+                keypress = new TKeypress(true, TKeypress.PGUP, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_PAGE_DOWN:
+                keypress = new TKeypress(true, TKeypress.PGDN, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_INSERT:
+                keypress = new TKeypress(true, TKeypress.INS, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_DELETE:
+                keypress = new TKeypress(true, TKeypress.DEL, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_RIGHT:
+                keypress = new TKeypress(true, TKeypress.RIGHT, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_LEFT:
+                keypress = new TKeypress(true, TKeypress.LEFT, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_UP:
+                keypress = new TKeypress(true, TKeypress.UP, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_DOWN:
+                keypress = new TKeypress(true, TKeypress.DOWN, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_TAB:
+                // Special case: distinguish TAB vs BTAB
+                if (shift) {
+                    keypress = kbShiftTab;
+                } else {
+                    keypress = kbTab;
+                }
+                break;
+            case KeyEvent.VK_ENTER:
+                keypress = new TKeypress(true, TKeypress.ENTER, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_ESCAPE:
+                keypress = new TKeypress(true, TKeypress.ESC, ' ',
+                    alt, ctrl, shift);
+                break;
+            case KeyEvent.VK_BACK_SPACE:
+                keypress = kbBackspace;
+                break;
+            default:
+                // Unsupported, ignore
+                return;
+            }
+        }
+
+        if (keypress == null) {
+            switch (ch) {
+            case 0x08:
+                // Disambiguate ^H from Backspace.
+                if (KeyEvent.getKeyText(key.getKeyCode()).equals("H")) {
+                    // This is ^H.
+                    keypress = kbBackspace;
+                } else {
+                    // We are emulating Xterm here, where the backspace key
+                    // on the keyboard returns ^?.
+                    keypress = kbBackspaceDel;
+                }
+                break;
+            case 0x0A:
+                keypress = kbEnter;
+                break;
+            case 0x1B:
+                keypress = kbEsc;
+                break;
+            case 0x0D:
+                keypress = kbEnter;
+                break;
+            case 0x09:
+                if (shift) {
+                    keypress = kbShiftTab;
+                } else {
+                    keypress = kbTab;
+                }
+                break;
+            case 0x7F:
+                keypress = kbDel;
+                break;
+            default:
+                if (!alt && ctrl && !shift) {
+                    // Control character, replace ch with 'A', 'B', etc.
+                    ch = KeyEvent.getKeyText(key.getKeyCode()).charAt(0);
+                }
+                // Not a special key, put it together
+                keypress = new TKeypress(false, 0, ch, alt, ctrl, shift);
+            }
+        }
+
+        // Save it and we are done.
+        synchronized (eventQueue) {
+            eventQueue.add(new TKeypressEvent(keypress));
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // WindowListener ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowActivated(final WindowEvent event) {
+        // Force a total repaint
+        synchronized (this) {
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowClosed(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowClosing(final WindowEvent event) {
+        // Drop a cmBackendDisconnect and walk away
+        synchronized (eventQueue) {
+            eventQueue.add(new TCommandEvent(cmBackendDisconnect));
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowDeactivated(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowDeiconified(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowIconified(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowOpened(final WindowEvent event) {
+        // Ignore
+    }
+
+    // ------------------------------------------------------------------------
+    // ComponentListener ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass component events into the event queue.
+     *
+     * @param event component event received
+     */
+    public void componentHidden(final ComponentEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass component events into the event queue.
+     *
+     * @param event component event received
+     */
+    public void componentShown(final ComponentEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass component events into the event queue.
+     *
+     * @param event component event received
+     */
+    public void componentMoved(final ComponentEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass component events into the event queue.
+     *
+     * @param event component event received
+     */
+    public void componentResized(final ComponentEvent event) {
+        if (gotFontDimensions == false) {
+            // We are still waiting to get font information.  Don't pass a
+            // resize event up.
+            // System.err.println("size " + swing.getComponent().getSize());
+            return;
+        }
+
+        if (sessionInfo == null) {
+            // This is the initial component resize in construction, bail
+            // out.
+            return;
+        }
+
+        // Drop a new TResizeEvent into the queue
+        sessionInfo.queryWindowSize();
+        synchronized (eventQueue) {
+            TResizeEvent windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+                sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
+            eventQueue.add(windowResize);
+            resetBlinkTimer();
+            /*
+            System.err.println("Add resize event: " + windowResize.getWidth() +
+                " x " + windowResize.getHeight());
+             */
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // MouseMotionListener ----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseDragged(final MouseEvent mouse) {
+        int modifiers = mouse.getModifiersEx();
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+            eventMouse1 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+            eventMouse2 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+            eventMouse3 = true;
+        }
+        if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+            eventAlt = true;
+        }
+        if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+            eventCtrl = true;
+        }
+        if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+            eventShift = true;
+        }
+
+        mouse1 = eventMouse1;
+        mouse2 = eventMouse2;
+        mouse3 = eventMouse3;
+        int x = textColumn(mouse.getX());
+        int y = textRow(mouse.getY());
+
+        TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_MOTION,
+            x, y, x, y, mouse1, mouse2, mouse3, false, false,
+            eventAlt, eventCtrl, eventShift);
+
+        synchronized (eventQueue) {
+            eventQueue.add(mouseEvent);
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseMoved(final MouseEvent mouse) {
+        int x = textColumn(mouse.getX());
+        int y = textRow(mouse.getY());
+        if ((x == oldMouseX) && (y == oldMouseY)) {
+            // Bail out, we've moved some pixels but not a whole text cell.
+            return;
+        }
+        oldMouseX = x;
+        oldMouseY = y;
+
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        int modifiers = mouse.getModifiersEx();
+        if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+            eventAlt = true;
+        }
+        if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+            eventCtrl = true;
+        }
+        if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+            eventShift = true;
+        }
+
+        TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_MOTION,
+            x, y, x, y, mouse1, mouse2, mouse3, false, false,
+            eventAlt, eventCtrl, eventShift);
+
+        synchronized (eventQueue) {
+            eventQueue.add(mouseEvent);
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // MouseListener ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseClicked(final MouseEvent mouse) {
+        // Ignore
+    }
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseEntered(final MouseEvent mouse) {
+        swing.requestFocusInWindow();
+    }
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseExited(final MouseEvent mouse) {
+        // Ignore
+    }
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mousePressed(final MouseEvent mouse) {
+        int modifiers = mouse.getModifiersEx();
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+            eventMouse1 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+            eventMouse2 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+            eventMouse3 = true;
+        }
+        if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+            eventAlt = true;
+        }
+        if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+            eventCtrl = true;
+        }
+        if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+            eventShift = true;
+        }
+
+        mouse1 = eventMouse1;
+        mouse2 = eventMouse2;
+        mouse3 = eventMouse3;
+        int x = textColumn(mouse.getX());
+        int y = textRow(mouse.getY());
+
+        TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_DOWN,
+            x, y, x, y, mouse1, mouse2, mouse3, false, false,
+            eventAlt, eventCtrl, eventShift);
+
+        synchronized (eventQueue) {
+            eventQueue.add(mouseEvent);
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseReleased(final MouseEvent mouse) {
+        int modifiers = mouse.getModifiersEx();
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+            eventMouse1 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+            eventMouse2 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+            eventMouse3 = true;
+        }
+        if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+            eventAlt = true;
+        }
+        if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+            eventCtrl = true;
+        }
+        if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+            eventShift = true;
+        }
+
+        if (mouse1) {
+            mouse1 = false;
+            eventMouse1 = true;
+        }
+        if (mouse2) {
+            mouse2 = false;
+            eventMouse2 = true;
+        }
+        if (mouse3) {
+            mouse3 = false;
+            eventMouse3 = true;
+        }
+        int x = textColumn(mouse.getX());
+        int y = textRow(mouse.getY());
+
+        TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_UP,
+            x, y, x, y, eventMouse1, eventMouse2, eventMouse3, false, false,
+            eventAlt, eventCtrl, eventShift);
+
+        synchronized (eventQueue) {
+            eventQueue.add(mouseEvent);
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // MouseWheelListener -----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass mouse events into the event queue.
+     *
+     * @param mouse mouse event received
+     */
+    public void mouseWheelMoved(final MouseWheelEvent mouse) {
+        int modifiers = mouse.getModifiersEx();
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean mouseWheelUp = false;
+        boolean mouseWheelDown = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+            eventMouse1 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+            eventMouse2 = true;
+        }
+        if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+            eventMouse3 = true;
+        }
+        if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+            eventAlt = true;
+        }
+        if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+            eventCtrl = true;
+        }
+        if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+            eventShift = true;
+        }
+
+        mouse1 = eventMouse1;
+        mouse2 = eventMouse2;
+        mouse3 = eventMouse3;
+        int x = textColumn(mouse.getX());
+        int y = textRow(mouse.getY());
+        if (mouse.getWheelRotation() > 0) {
+            mouseWheelDown = true;
+        }
+        if (mouse.getWheelRotation() < 0) {
+            mouseWheelUp = true;
+        }
+
+        TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_DOWN,
+            x, y, x, y, mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown,
+            eventAlt, eventCtrl, eventShift);
+
+        synchronized (eventQueue) {
+            eventQueue.add(mouseEvent);
+            resetBlinkTimer();
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/backend/TSessionInfo.java b/src/jexer/backend/TSessionInfo.java
new file mode 100644 (file)
index 0000000..ccddce4
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * 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.backend;
+
+/**
+ * TSessionInfo provides a default session implementation.  The username is
+ * blank, language is "en_US", with a 80x24 text window.
+ */
+public class TSessionInfo implements SessionInfo {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * User name.
+     */
+    private String username = "";
+
+    /**
+     * Language.
+     */
+    private String language = "en_US";
+
+    /**
+     * Text window width.
+     */
+    private int windowWidth = 80;
+
+    /**
+     * Text window height.
+     */
+    private int windowHeight = 24;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     */
+    public TSessionInfo() {
+        this(80, 24);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param width the number of columns
+     * @param height the number of rows
+     */
+    public TSessionInfo(final int width, final int height) {
+        this.windowWidth        = width;
+        this.windowHeight       = height;
+    }
+
+    // ------------------------------------------------------------------------
+    // SessionInfo ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Username getter.
+     *
+     * @return the username
+     */
+    public String getUsername() {
+        return this.username;
+    }
+
+    /**
+     * Username setter.
+     *
+     * @param username the value
+     */
+    public void setUsername(final String username) {
+        this.username = username;
+    }
+
+    /**
+     * Language getter.
+     *
+     * @return the language
+     */
+    public String getLanguage() {
+        return this.language;
+    }
+
+    /**
+     * Language setter.
+     *
+     * @param language the value
+     */
+    public void setLanguage(final String language) {
+        this.language = language;
+    }
+
+    /**
+     * Text window width getter.
+     *
+     * @return the window width
+     */
+    public int getWindowWidth() {
+        return windowWidth;
+    }
+
+    /**
+     * Text window height getter.
+     *
+     * @return the window height
+     */
+    public int getWindowHeight() {
+        return windowHeight;
+    }
+
+    /**
+     * Re-query the text window size.
+     */
+    public void queryWindowSize() {
+        // NOP
+    }
+
+}
diff --git a/src/jexer/backend/TTYSessionInfo.java b/src/jexer/backend/TTYSessionInfo.java
new file mode 100644 (file)
index 0000000..d7f5bc8
--- /dev/null
@@ -0,0 +1,228 @@
+/*
+ * 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.backend;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.util.StringTokenizer;
+
+/**
+ * TTYSessionInfo queries environment variables and the tty window size for
+ * the session information.  The username is taken from user.name, language
+ * is taken from user.language, and text window size from 'stty size'.
+ */
+public class TTYSessionInfo implements SessionInfo {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * User name.
+     */
+    private String username = "";
+
+    /**
+     * Language.
+     */
+    private String language = "";
+
+    /**
+     * Text window width.  Default is 80x24 (same as VT100-ish terminals).
+     */
+    private int windowWidth = 80;
+
+    /**
+     * Text window height.  Default is 80x24 (same as VT100-ish terminals).
+     */
+    private int windowHeight = 24;
+
+    /**
+     * Time at which the window size was refreshed.
+     */
+    private long lastQueryWindowTime;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     */
+    public TTYSessionInfo() {
+        // Populate lang and user from the environment
+        username = System.getProperty("user.name");
+        language = System.getProperty("user.language");
+        queryWindowSize();
+    }
+
+    // ------------------------------------------------------------------------
+    // SessionInfo ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Username getter.
+     *
+     * @return the username
+     */
+    public String getUsername() {
+        return this.username;
+    }
+
+    /**
+     * Username setter.
+     *
+     * @param username the value
+     */
+    public void setUsername(final String username) {
+        this.username = username;
+    }
+
+    /**
+     * Language getter.
+     *
+     * @return the language
+     */
+    public String getLanguage() {
+        return this.language;
+    }
+
+    /**
+     * Language setter.
+     *
+     * @param language the value
+     */
+    public void setLanguage(final String language) {
+        this.language = language;
+    }
+
+    /**
+     * Text window width getter.
+     *
+     * @return the window width
+     */
+    public int getWindowWidth() {
+        if (System.getProperty("os.name").startsWith("Windows")) {
+            // Always use 80x25 for Windows (same as DOS)
+            return 80;
+        }
+        return windowWidth;
+    }
+
+    /**
+     * Text window height getter.
+     *
+     * @return the window height
+     */
+    public int getWindowHeight() {
+        if (System.getProperty("os.name").startsWith("Windows")) {
+            // Always use 80x25 for Windows (same as DOS)
+            return 25;
+        }
+        return windowHeight;
+    }
+
+    /**
+     * Re-query the text window size.
+     */
+    public void queryWindowSize() {
+        if (lastQueryWindowTime == 0) {
+            lastQueryWindowTime = System.currentTimeMillis();
+        } else {
+            long nowTime = System.currentTimeMillis();
+            if (nowTime - lastQueryWindowTime < 1000) {
+                // Don't re-spawn stty if it hasn't been a full second since
+                // the last time.
+                return;
+            }
+        }
+        if (System.getProperty("os.name").startsWith("Linux")
+            || System.getProperty("os.name").startsWith("Mac OS X")
+            || System.getProperty("os.name").startsWith("SunOS")
+            || System.getProperty("os.name").startsWith("FreeBSD")
+        ) {
+            // Use stty to get the window size
+            sttyWindowSize();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TTYSessionInfo ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Call 'stty size' to obtain the tty window size.  windowWidth and
+     * windowHeight are set automatically.
+     */
+    private void sttyWindowSize() {
+        String [] cmd = {
+            "/bin/sh", "-c", "stty size < /dev/tty"
+        };
+        try {
+            Process process = Runtime.getRuntime().exec(cmd);
+            BufferedReader in = new BufferedReader(
+                new InputStreamReader(process.getInputStream(), "UTF-8"));
+            String line = in.readLine();
+            if ((line != null) && (line.length() > 0)) {
+                StringTokenizer tokenizer = new StringTokenizer(line);
+                int rc = Integer.parseInt(tokenizer.nextToken());
+                if (rc > 0) {
+                    windowHeight = rc;
+                }
+                rc = Integer.parseInt(tokenizer.nextToken());
+                if (rc > 0) {
+                    windowWidth = rc;
+                }
+            }
+            while (true) {
+                BufferedReader err = new BufferedReader(
+                        new InputStreamReader(process.getErrorStream(),
+                            "UTF-8"));
+                line = err.readLine();
+                if ((line != null) && (line.length() > 0)) {
+                    System.err.println("Error output from stty: " + line);
+                }
+                try {
+                    process.waitFor();
+                    break;
+                } catch (InterruptedException e) {
+                    // SQUASH
+                }
+            }
+            int rc = process.exitValue();
+            if (rc != 0) {
+                System.err.println("stty returned error code: " + rc);
+            }
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+}
diff --git a/src/jexer/backend/TWindowBackend.java b/src/jexer/backend/TWindowBackend.java
new file mode 100644 (file)
index 0000000..f644b76
--- /dev/null
@@ -0,0 +1,543 @@
+/*
+ * 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.backend;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.TApplication;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.event.TInputEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+
+/**
+ * TWindowBackend uses a window in one TApplication to provide a backend for
+ * another TApplication.
+ *
+ * Note that TWindow has its own getScreen() and setTitle() functions.
+ * Clients in TWindowBackend's application won't be able to use it to get at
+ * the other application's screen.  getOtherScreen() has been provided.
+ */
+public class TWindowBackend extends TWindow implements Backend {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The listening object that run() wakes up on new input.
+     */
+    private Object listener;
+
+    /**
+     * The object to sync on in draw().  This is normally otherScreen, but it
+     * could also be a MultiScreen.
+     */
+    private Object drawLock;
+
+    /**
+     * The event queue, filled up by a thread reading on input.
+     */
+    private List<TInputEvent> eventQueue;
+
+    /**
+     * The screen this window is monitoring.
+     */
+    private Screen otherScreen;
+
+    /**
+     * The application associated with otherScreen.
+     */
+    private TApplication otherApplication;
+
+    /**
+     * The session information.
+     */
+    private SessionInfo sessionInfo;
+
+    /**
+     * OtherScreen provides a hook to notify TWindowBackend of screen size
+     * changes.
+     */
+    private class OtherScreen extends LogicalScreen {
+
+        /**
+         * The TWindowBackend to notify.
+         */
+        private TWindowBackend window;
+
+        /**
+         * Public constructor.
+         */
+        public OtherScreen(final TWindowBackend window) {
+            this.window = window;
+        }
+
+        /**
+         * Resize the physical screen to match the logical screen dimensions.
+         */
+        @Override
+        public void resizeToScreen() {
+            window.setWidth(getWidth() + 2);
+            window.setHeight(getHeight() + 2);
+        }
+
+        /**
+         * Get the width of a character cell in pixels.
+         *
+         * @return the width in pixels of a character cell
+         */
+        @Override
+        public int getTextWidth() {
+            return window.getScreen().getTextWidth();
+        }
+
+        /**
+         * Get the height of a character cell in pixels.
+         *
+         * @return the height in pixels of a character cell
+         */
+        @Override
+        public int getTextHeight() {
+            return window.getScreen().getTextHeight();
+        }
+
+    }
+
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.  Window will be located at (0, 0).
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     */
+    public TWindowBackend(final Object listener,
+        final TApplication application, final String title,
+        final int width, final int height) {
+
+        super(application, title, width, height);
+
+        this.listener = listener;
+        eventQueue = new ArrayList<TInputEvent>();
+        sessionInfo = new TSessionInfo(width, height);
+        otherScreen = new OtherScreen(this);
+        otherScreen.setDimensions(width - 2, height - 2);
+        drawLock = otherScreen;
+        setHiddenMouse(true);
+    }
+
+    /**
+     * Public constructor.  Window will be located at (0, 0).
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param width width of window
+     * @param height height of window
+     * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+     */
+    public TWindowBackend(final Object listener,
+        final TApplication application, final String title,
+        final int width, final int height, final int flags) {
+
+        super(application, title, width, height, flags);
+
+        this.listener = listener;
+        eventQueue = new ArrayList<TInputEvent>();
+        sessionInfo = new TSessionInfo(width, height);
+        otherScreen = new OtherScreen(this);
+        otherScreen.setDimensions(width - 2, height - 2);
+        drawLock = otherScreen;
+        setHiddenMouse(true);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     */
+    public TWindowBackend(final Object listener,
+        final TApplication application, final String title,
+        final int x, final int y, final int width, final int height) {
+
+        super(application, title, x, y, width, height);
+
+        this.listener = listener;
+        eventQueue = new ArrayList<TInputEvent>();
+        sessionInfo = new TSessionInfo(width, height);
+        otherScreen = new OtherScreen(this);
+        otherScreen.setDimensions(width - 2, height - 2);
+        drawLock = otherScreen;
+        setHiddenMouse(true);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param application TApplication that manages this window
+     * @param title window title, will be centered along the top border
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of window
+     * @param height height of window
+     * @param flags mask of RESIZABLE, CENTERED, or MODAL
+     */
+    public TWindowBackend(final Object listener,
+        final TApplication application, final String title,
+        final int x, final int y, final int width, final int height,
+        final int flags) {
+
+        super(application, title, x, y, width, height, flags);
+
+        this.listener = listener;
+        eventQueue = new ArrayList<TInputEvent>();
+        sessionInfo = new TSessionInfo(width, height);
+        otherScreen = new OtherScreen(this);
+        otherScreen.setDimensions(width - 2, height - 2);
+        drawLock = otherScreen;
+        setHiddenMouse(true);
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            int newWidth = event.getWidth() - 2;
+            int newHeight = event.getHeight() - 2;
+            if ((newWidth != otherScreen.getWidth())
+                || (newHeight != otherScreen.getHeight())
+            ) {
+                // I was resized, notify the screen I am watching to match my
+                // new size.
+                synchronized (eventQueue) {
+                    eventQueue.add(new TResizeEvent(TResizeEvent.Type.SCREEN,
+                            newWidth, newHeight));
+                }
+                synchronized (listener) {
+                    listener.notifyAll();
+                }
+            }
+            return;
+        } else {
+            super.onResize(event);
+        }
+    }
+
+    /**
+     * Returns true if the mouse is currently in the otherScreen window.
+     *
+     * @param mouse mouse event
+     * @return true if mouse is currently in the otherScreen window.
+     */
+    protected boolean mouseOnOtherScreen(final TMouseEvent mouse) {
+        if ((mouse.getY() >= 1)
+            && (mouse.getY() <= otherScreen.getHeight())
+            && (mouse.getX() >= 1)
+            && (mouse.getX() <= otherScreen.getWidth())
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouseOnOtherScreen(mouse)) {
+            TMouseEvent event = mouse.dup();
+            event.setX(mouse.getX() - 1);
+            event.setY(mouse.getY() - 1);
+            event.setAbsoluteX(event.getX());
+            event.setAbsoluteY(event.getY());
+            synchronized (eventQueue) {
+                eventQueue.add(event);
+            }
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+        super.onMouseDown(mouse);
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        if (mouseOnOtherScreen(mouse)) {
+            TMouseEvent event = mouse.dup();
+            event.setX(mouse.getX() - 1);
+            event.setY(mouse.getY() - 1);
+            event.setAbsoluteX(event.getX());
+            event.setAbsoluteY(event.getY());
+            synchronized (eventQueue) {
+                eventQueue.add(event);
+            }
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+        super.onMouseUp(mouse);
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        if (mouseOnOtherScreen(mouse)) {
+            TMouseEvent event = mouse.dup();
+            event.setX(mouse.getX() - 1);
+            event.setY(mouse.getY() - 1);
+            event.setAbsoluteX(event.getX());
+            event.setAbsoluteY(event.getY());
+            synchronized (eventQueue) {
+                eventQueue.add(event);
+            }
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
+        super.onMouseMotion(mouse);
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        TKeypressEvent event = keypress.dup();
+        synchronized (eventQueue) {
+            eventQueue.add(event);
+        }
+        synchronized (listener) {
+            listener.notifyAll();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the foreground colors grid.
+     */
+    @Override
+    public void draw() {
+
+        // Sync on other screen, so that we do not draw in the middle of
+        // their screen update.
+        synchronized (drawLock) {
+            // Draw the box
+            super.draw();
+
+            // Draw every cell of the other screen
+            for (int y = 0; y < otherScreen.getHeight(); y++) {
+                for (int x = 0; x < otherScreen.getWidth(); x++) {
+                    putCharXY(x + 1, y + 1, otherScreen.getCharXY(x, y));
+                }
+            }
+
+            // If their cursor is visible, draw that here too.
+            if (otherScreen.isCursorVisible()) {
+                setCursorX(otherScreen.getCursorX() + 1);
+                setCursorY(otherScreen.getCursorY() + 1);
+                setCursorVisible(true);
+            } else {
+                setCursorVisible(false);
+            }
+        }
+
+        // Check if the other application has died.  If so, unset hidden
+        // mouse.
+        if (otherApplication != null) {
+            if (otherApplication.isRunning() == false) {
+                setHiddenMouse(false);
+            }
+        }
+
+    }
+
+    /**
+     * Subclasses should override this method to cleanup resources.  This is
+     * called by application.closeWindow().
+     */
+    @Override
+    public void onClose() {
+        synchronized (eventQueue) {
+            eventQueue.add(new TCommandEvent(cmBackendDisconnect));
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Backend ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Getter for sessionInfo.
+     *
+     * @return the SessionInfo
+     */
+    public final SessionInfo getSessionInfo() {
+        return sessionInfo;
+    }
+
+    /**
+     * Subclasses must provide an implementation that syncs the logical
+     * screen to the physical device.
+     */
+    public void flushScreen() {
+        getApplication().doRepaint();
+    }
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the application
+     */
+    public boolean hasEvents() {
+        synchronized (eventQueue) {
+            return (eventQueue.size() > 0);
+        }
+    }
+
+    /**
+     * Subclasses must provide an implementation to get keyboard, mouse, and
+     * screen resize events.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(List<TInputEvent> queue) {
+        synchronized (eventQueue) {
+            if (eventQueue.size() > 0) {
+                synchronized (queue) {
+                    queue.addAll(eventQueue);
+                }
+                eventQueue.clear();
+            }
+        }
+    }
+
+    /**
+     * Subclasses must provide an implementation that closes sockets,
+     * restores console, etc.
+     */
+    public void shutdown() {
+        // NOP
+    }
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener) {
+        this.listener = listener;
+    }
+
+    /**
+     * Reload backend options from System properties.
+     */
+    public void reloadOptions() {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindowBackend ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the object to sync to in draw().
+     *
+     * @param drawLock the object to synchronize on
+     */
+    public void setDrawLock(final Object drawLock) {
+        this.drawLock = drawLock;
+    }
+
+    /**
+     * Getter for the other application's screen.
+     *
+     * @return the Screen
+     */
+    public Screen getOtherScreen() {
+        return otherScreen;
+    }
+
+    /**
+     * Set the other screen's application.
+     *
+     * @param application the application driving the other screen
+     */
+    public void setOtherApplication(final TApplication application) {
+        this.otherApplication = application;
+    }
+
+}
diff --git a/src/jexer/backend/TerminalReader.java b/src/jexer/backend/TerminalReader.java
new file mode 100644 (file)
index 0000000..32033e0
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * 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.backend;
+
+import java.util.List;
+
+import jexer.event.TInputEvent;
+
+/**
+ * TerminalReader provides keyboard and mouse events.
+ */
+public interface TerminalReader {
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the backend
+     */
+    public boolean hasEvents();
+
+    /**
+     * Classes must provide an implementation to get keyboard, mouse, and
+     * screen resize events.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(List<TInputEvent> queue);
+
+    /**
+     * Classes must provide an implementation that closes sockets, restores
+     * console, etc.
+     */
+    public void closeTerminal();
+
+    /**
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
+     */
+    public void setListener(final Object listener);
+
+    /**
+     * Reload options from System properties.
+     */
+    public void reloadOptions();
+
+}
diff --git a/src/jexer/backend/package-info.java b/src/jexer/backend/package-info.java
new file mode 100644 (file)
index 0000000..46d8ba1
--- /dev/null
@@ -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
+ */
+
+/**
+ * The interface between TApplication and user-facing I/O.
+ */
+package jexer.backend;
diff --git a/src/jexer/bits/Cell.java b/src/jexer/bits/Cell.java
new file mode 100644 (file)
index 0000000..ed3c202
--- /dev/null
@@ -0,0 +1,485 @@
+/*
+ * 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.bits;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+
+/**
+ * This class represents a single text cell or bit of image on the screen.
+ */
+public final class Cell extends CellAttributes {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * How this cell needs to be displayed if it is part of a larger glyph.
+     */
+    public enum Width {
+        /**
+         * This cell is an entire glyph on its own.
+         */
+        SINGLE,
+
+        /**
+         * This cell is the left half of a wide glyph.
+         */
+        LEFT,
+
+        /**
+         * This cell is the right half of a wide glyph.
+         */
+        RIGHT,
+    }
+
+    /**
+     * The special "this cell is unset" (null) value.  This is the Unicode
+     * "not a character" value.
+     */
+    private static final char UNSET_VALUE = (char) 65535;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The character at this cell.
+     */
+    private int ch = ' ';
+
+    /**
+     * The display width of this cell.
+     */
+    private Width width = Width.SINGLE;
+
+    /**
+     * The image at this cell.
+     */
+    private BufferedImage image = null;
+
+    /**
+     * The image at this cell, inverted.
+     */
+    private BufferedImage invertedImage = null;
+
+    /**
+     * The background color used for the area the image portion might not
+     * cover.
+     */
+    private Color background = Color.BLACK;
+
+    /**
+     * hashCode() needs to call image.hashCode(), which can get quite
+     * expensive.
+     */
+    private int imageHashCode = 0;
+
+    /**
+     * hashCode() needs to call background.hashCode(), which can get quite
+     * expensive.
+     */
+    private int backgroundHashCode = 0;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor sets default values of the cell to blank.
+     *
+     * @see #isBlank()
+     * @see #reset()
+     */
+    public Cell() {
+        // NOP
+    }
+
+    /**
+     * Public constructor sets the character.  Attributes are the same as
+     * default.
+     *
+     * @param ch character to set to
+     * @see #reset()
+     */
+    public Cell(final int ch) {
+        this.ch = ch;
+    }
+
+    /**
+     * Public constructor sets the attributes.
+     *
+     * @param attr attributes to use
+     */
+    public Cell(final CellAttributes attr) {
+        super(attr);
+    }
+
+    /**
+     * Public constructor sets the character and attributes.
+     *
+     * @param ch character to set to
+     * @param attr attributes to use
+     */
+    public Cell(final int ch, final CellAttributes attr) {
+        super(attr);
+        this.ch = ch;
+    }
+
+    /**
+     * Public constructor creates a duplicate.
+     *
+     * @param cell the instance to copy
+     */
+    public Cell(final Cell cell) {
+        setTo(cell);
+    }
+
+    // ------------------------------------------------------------------------
+    // Cell -------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the image data for this cell.
+     *
+     * @param image the image for this cell
+     */
+    public void setImage(final BufferedImage image) {
+        this.image = image;
+        imageHashCode = image.hashCode();
+        width = Width.SINGLE;
+    }
+
+    /**
+     * Get the image data for this cell.
+     *
+     * @return the image for this cell
+     */
+    public BufferedImage getImage() {
+        if (invertedImage != null) {
+            return invertedImage;
+        }
+        return image;
+    }
+
+    /**
+     * Get the bitmap image background color for this cell.
+     *
+     * @return the bitmap image background color
+     */
+    public Color getBackground() {
+        return background;
+    }
+
+    /**
+     * If true, this cell has image data.
+     *
+     * @return true if this cell is an image rather than a character with
+     * attributes
+     */
+    public boolean isImage() {
+        if (image != null) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Restore the image in this cell to its normal version, if it has one.
+     */
+    public void restoreImage() {
+        invertedImage = null;
+    }
+
+    /**
+     * If true, this cell has image data, and that data is inverted.
+     *
+     * @return true if this cell is an image rather than a character with
+     * attributes, and the data is inverted
+     */
+    public boolean isInvertedImage() {
+        if ((image != null) && (invertedImage != null)) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Invert the image in this cell, if it has one.
+     */
+    public void invertImage() {
+        if (image == null) {
+            return;
+        }
+        if (invertedImage == null) {
+            invertedImage = new BufferedImage(image.getWidth(),
+                image.getHeight(), BufferedImage.TYPE_INT_ARGB);
+
+            int [] rgbArray = image.getRGB(0, 0,
+                image.getWidth(), image.getHeight(), null, 0, image.getWidth());
+
+            for (int i = 0; i < rgbArray.length; i++) {
+                // Set the colors to fully inverted.
+                if (rgbArray[i] != 0x00FFFFFF) {
+                    rgbArray[i] ^= 0x00FFFFFF;
+                }
+                // Also set alpha to non-transparent.
+                rgbArray[i] |= 0xFF000000;
+            }
+            invertedImage.setRGB(0, 0, image.getWidth(), image.getHeight(),
+                rgbArray, 0, image.getWidth());
+        }
+    }
+
+    /**
+     * Getter for cell character.
+     *
+     * @return cell character
+     */
+    public int getChar() {
+        return ch;
+    }
+
+    /**
+     * Setter for cell character.
+     *
+     * @param ch new cell character
+     */
+    public void setChar(final int ch) {
+        this.ch = ch;
+    }
+
+    /**
+     * Getter for cell width.
+     *
+     * @return Width.SINGLE, Width.LEFT, or Width.RIGHT
+     */
+    public Width getWidth() {
+        return width;
+    }
+
+    /**
+     * Setter for cell width.
+     *
+     * @param width new cell width, one of Width.SINGLE, Width.LEFT, or
+     * Width.RIGHT
+     */
+    public void setWidth(final Width width) {
+        this.width = width;
+    }
+
+    /**
+     * Reset this cell to a blank.
+     */
+    @Override
+    public void reset() {
+        super.reset();
+        ch = ' ';
+        width = Width.SINGLE;
+        image = null;
+        imageHashCode = 0;
+        invertedImage = null;
+        background = Color.BLACK;
+        backgroundHashCode = 0;
+    }
+
+    /**
+     * UNset this cell.  It will not be equal to any other cell until it has
+     * been assigned attributes and a character.
+     */
+    public void unset() {
+        super.reset();
+        ch = UNSET_VALUE;
+        width = Width.SINGLE;
+        image = null;
+        imageHashCode = 0;
+        invertedImage = null;
+        background = Color.BLACK;
+        backgroundHashCode = 0;
+    }
+
+    /**
+     * Check to see if this cell has default attributes: white foreground,
+     * black background, no bold/blink/reverse/underline/protect, and a
+     * character value of ' ' (space).
+     *
+     * @return true if this cell has default attributes.
+     */
+    public boolean isBlank() {
+        if ((ch == UNSET_VALUE) || (image != null)) {
+            return false;
+        }
+        if ((getForeColor().equals(Color.WHITE))
+            && (getBackColor().equals(Color.BLACK))
+            && !isBold()
+            && !isBlink()
+            && !isReverse()
+            && !isUnderline()
+            && !isProtect()
+            && !isRGB()
+            && !isImage()
+            && (width == Width.SINGLE)
+            && (ch == ' ')
+        ) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another Cell instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public boolean equals(final Object rhs) {
+        if (!(rhs instanceof Cell)) {
+            return false;
+        }
+
+        Cell that = (Cell) rhs;
+
+        // Unsetted cells can never be equal.
+        if ((ch == UNSET_VALUE) || (that.ch == UNSET_VALUE)) {
+            return false;
+        }
+
+        // If this or rhs has an image and the other doesn't, these are not
+        // equal.
+        if ((image != null) && (that.image == null)) {
+            return false;
+        }
+        if ((image == null) && (that.image != null)) {
+            return false;
+        }
+        // If this and rhs have images, both must match.
+        if ((image != null) && (that.image != null)) {
+            if ((invertedImage == null) && (that.invertedImage != null)) {
+                return false;
+            }
+            if ((invertedImage != null) && (that.invertedImage == null)) {
+                return false;
+            }
+            // Either both objects have their image inverted, or neither do.
+            // Now if the images are identical the cells are the same
+            // visually.
+            if (image.equals(that.image)
+                && (background.equals(that.background))
+            ) {
+                return true;
+            } else {
+                return false;
+            }
+        }
+
+        // Normal case: character and attributes must match.
+        if ((ch == that.ch) && (width == that.width)) {
+            return super.equals(rhs);
+        }
+        return false;
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        int A = 13;
+        int B = 23;
+        int hash = A;
+        hash = (B * hash) + super.hashCode();
+        hash = (B * hash) + ch;
+        hash = (B * hash) + width.hashCode();
+        if (image != null) {
+            /*
+            hash = (B * hash) + image.hashCode();
+            hash = (B * hash) + background.hashCode();
+             */
+            hash = (B * hash) + imageHashCode;
+            hash = (B * hash) + backgroundHashCode;
+        }
+        if (invertedImage != null) {
+            hash = (B * hash) + invertedImage.hashCode();
+        }
+        return hash;
+    }
+
+    /**
+     * Set my field values to that's field.
+     *
+     * @param rhs an instance of either Cell or CellAttributes
+     */
+    @Override
+    public void setTo(final Object rhs) {
+        // Let this throw a ClassCastException
+        CellAttributes thatAttr = (CellAttributes) rhs;
+        this.image = null;
+        this.imageHashCode = 0;
+        this.backgroundHashCode = 0;
+        this.width = Width.SINGLE;
+        super.setTo(thatAttr);
+
+        if (rhs instanceof Cell) {
+            Cell that = (Cell) rhs;
+            this.ch = that.ch;
+            this.width = that.width;
+            this.image = that.image;
+            this.invertedImage = that.invertedImage;
+            this.background = that.background;
+            this.imageHashCode = that.imageHashCode;
+            this.backgroundHashCode = that.backgroundHashCode;
+        }
+    }
+
+    /**
+     * Set my field attr values to that's field.
+     *
+     * @param that a CellAttributes instance
+     */
+    public void setAttr(final CellAttributes that) {
+        image = null;
+        super.setTo(that);
+    }
+
+    /**
+     * Make human-readable description of this Cell.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return String.format("fore: %s back: %s bold: %s blink: %s ch %c",
+            getForeColor(), getBackColor(), isBold(), isBlink(), ch);
+    }
+}
diff --git a/src/jexer/bits/CellAttributes.java b/src/jexer/bits/CellAttributes.java
new file mode 100644 (file)
index 0000000..ad86198
--- /dev/null
@@ -0,0 +1,394 @@
+/*
+ * 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.bits;
+
+/**
+ * The attributes used by a Cell: color, bold, blink, etc.
+ */
+public class CellAttributes {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Bold attribute.
+     */
+    private static final int BOLD       = 0x01;
+
+    /**
+     * Blink attribute.
+     */
+    private static final int BLINK      = 0x02;
+
+    /**
+     * Reverse attribute.
+     */
+    private static final int REVERSE    = 0x04;
+
+    /**
+     * Underline attribute.
+     */
+    private static final int UNDERLINE  = 0x08;
+
+    /**
+     * Protected attribute.
+     */
+    private static final int PROTECT    = 0x10;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Boolean flags.
+     */
+    private int flags = 0;
+
+    /**
+     * Foreground color.  Color.WHITE, Color.RED, etc.
+     */
+    private Color foreColor = Color.WHITE;
+
+    /**
+     * Background color.  Color.WHITE, Color.RED, etc.
+     */
+    private Color backColor = Color.BLACK;
+
+    /**
+     * Foreground color as 24-bit RGB value.  Negative value means not set.
+     */
+    private int foreColorRGB = -1;
+
+    /**
+     * Background color as 24-bit RGB value.  Negative value means not set.
+     */
+    private int backColorRGB = -1;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor sets default values of the cell to white-on-black,
+     * no bold/blink/reverse/underline/protect.
+     *
+     * @see #reset()
+     */
+    public CellAttributes() {
+        // NOP
+    }
+
+    /**
+     * Public constructor makes a copy from another instance.
+     *
+     * @param that another CellAttributes instance
+     * @see #reset()
+     */
+    public CellAttributes(final CellAttributes that) {
+        setTo(that);
+    }
+
+    // ------------------------------------------------------------------------
+    // CellAttributes ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Getter for bold.
+     *
+     * @return bold value
+     */
+    public final boolean isBold() {
+        return ((flags & BOLD) == 0 ? false : true);
+    }
+
+    /**
+     * Setter for bold.
+     *
+     * @param bold new bold value
+     */
+    public final void setBold(final boolean bold) {
+        if (bold) {
+            flags |= BOLD;
+        } else {
+            flags &= ~BOLD;
+        }
+    }
+
+    /**
+     * Getter for blink.
+     *
+     * @return blink value
+     */
+    public final boolean isBlink() {
+        return ((flags & BLINK) == 0 ? false : true);
+    }
+
+    /**
+     * Setter for blink.
+     *
+     * @param blink new blink value
+     */
+    public final void setBlink(final boolean blink) {
+        if (blink) {
+            flags |= BLINK;
+        } else {
+            flags &= ~BLINK;
+        }
+    }
+
+    /**
+     * Getter for reverse.
+     *
+     * @return reverse value
+     */
+    public final boolean isReverse() {
+        return ((flags & REVERSE) == 0 ? false : true);
+    }
+
+    /**
+     * Setter for reverse.
+     *
+     * @param reverse new reverse value
+     */
+    public final void setReverse(final boolean reverse) {
+        if (reverse) {
+            flags |= REVERSE;
+        } else {
+            flags &= ~REVERSE;
+        }
+    }
+
+    /**
+     * Getter for underline.
+     *
+     * @return underline value
+     */
+    public final boolean isUnderline() {
+        return ((flags & UNDERLINE) == 0 ? false : true);
+    }
+
+    /**
+     * Setter for underline.
+     *
+     * @param underline new underline value
+     */
+    public final void setUnderline(final boolean underline) {
+        if (underline) {
+            flags |= UNDERLINE;
+        } else {
+            flags &= ~UNDERLINE;
+        }
+    }
+
+    /**
+     * Getter for protect.
+     *
+     * @return protect value
+     */
+    public final boolean isProtect() {
+        return ((flags & PROTECT) == 0 ? false : true);
+    }
+
+    /**
+     * Setter for protect.
+     *
+     * @param protect new protect value
+     */
+    public final void setProtect(final boolean protect) {
+        if (protect) {
+            flags |= PROTECT;
+        } else {
+            flags &= ~PROTECT;
+        }
+    }
+
+    /**
+     * Getter for foreColor.
+     *
+     * @return foreColor value
+     */
+    public final Color getForeColor() {
+        return foreColor;
+    }
+
+    /**
+     * Setter for foreColor.
+     *
+     * @param foreColor new foreColor value
+     */
+    public final void setForeColor(final Color foreColor) {
+        this.foreColor = foreColor;
+    }
+
+    /**
+     * Getter for backColor.
+     *
+     * @return backColor value
+     */
+    public final Color getBackColor() {
+        return backColor;
+    }
+
+    /**
+     * Setter for backColor.
+     *
+     * @param backColor new backColor value
+     */
+    public final void setBackColor(final Color backColor) {
+        this.backColor = backColor;
+    }
+
+    /**
+     * Getter for foreColor RGB.
+     *
+     * @return foreColor value.  Negative means unset.
+     */
+    public final int getForeColorRGB() {
+        return foreColorRGB;
+    }
+
+    /**
+     * Setter for foreColor RGB.
+     *
+     * @param foreColorRGB new foreColor RGB value
+     */
+    public final void setForeColorRGB(final int foreColorRGB) {
+        this.foreColorRGB = foreColorRGB;
+    }
+
+    /**
+     * Getter for backColor RGB.
+     *
+     * @return backColor value.  Negative means unset.
+     */
+    public final int getBackColorRGB() {
+        return backColorRGB;
+    }
+
+    /**
+     * Setter for backColor RGB.
+     *
+     * @param backColorRGB new backColor RGB value
+     */
+    public final void setBackColorRGB(final int backColorRGB) {
+        this.backColorRGB = backColorRGB;
+    }
+
+    /**
+     * See if this cell uses RGB or ANSI colors.
+     *
+     * @return true if this cell has a RGB color
+     */
+    public final boolean isRGB() {
+        return (foreColorRGB >= 0) || (backColorRGB >= 0);
+    }
+
+    /**
+     * Set to default: white foreground on black background, no
+     * bold/underline/blink/rever/protect.
+     */
+    public void reset() {
+        flags           = 0;
+        foreColor       = Color.WHITE;
+        backColor       = Color.BLACK;
+        foreColorRGB    = -1;
+        backColorRGB    = -1;
+    }
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another CellAttributes instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public boolean equals(final Object rhs) {
+        if (!(rhs instanceof CellAttributes)) {
+            return false;
+        }
+
+        CellAttributes that = (CellAttributes) rhs;
+        return ((flags == that.flags)
+            && (foreColor == that.foreColor)
+            && (backColor == that.backColor)
+            && (foreColorRGB == that.foreColorRGB)
+            && (backColorRGB == that.backColorRGB));
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        int A = 13;
+        int B = 23;
+        int hash = A;
+        hash = (B * hash) + flags;
+        hash = (B * hash) + foreColor.hashCode();
+        hash = (B * hash) + backColor.hashCode();
+        hash = (B * hash) + foreColorRGB;
+        hash = (B * hash) + backColorRGB;
+        return hash;
+    }
+
+    /**
+     * Set my field values to that's field.
+     *
+     * @param rhs another CellAttributes instance
+     */
+    public void setTo(final Object rhs) {
+        CellAttributes that = (CellAttributes) rhs;
+
+        this.flags              = that.flags;
+        this.foreColor          = that.foreColor;
+        this.backColor          = that.backColor;
+        this.foreColorRGB       = that.foreColorRGB;
+        this.backColorRGB       = that.backColorRGB;
+    }
+
+    /**
+     * Make human-readable description of this CellAttributes.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        if ((foreColorRGB >= 0) || (backColorRGB >= 0)) {
+            return String.format("RGB: #%06x on #%06x",
+                (foreColorRGB & 0xFFFFFF),
+                (backColorRGB & 0xFFFFFF));
+        }
+        return String.format("%s%s%s on %s", (isBold() ? "bold " : ""),
+            (isBlink() ? "blink " : ""), foreColor, backColor);
+    }
+
+}
diff --git a/src/jexer/bits/Clipboard.java b/src/jexer/bits/Clipboard.java
new file mode 100644 (file)
index 0000000..5c1ea9a
--- /dev/null
@@ -0,0 +1,276 @@
+/*
+ * 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.bits;
+
+import java.awt.Image;
+import java.awt.Toolkit;
+import java.awt.datatransfer.DataFlavor;
+import java.awt.datatransfer.StringSelection;
+import java.awt.datatransfer.Transferable;
+import java.awt.datatransfer.UnsupportedFlavorException;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+
+/**
+ * Clipboard provides convenience methods to copy text and images to and from
+ * a shared clipboard.  When the system clipboard is available it is used.
+ */
+public class Clipboard {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The image last copied to the clipboard.
+     */
+    private BufferedImage image = null;
+
+    /**
+     * The text string last copied to the clipboard.
+     */
+    private String text = null;
+
+    /**
+     * The system clipboard, or null if it is not available.
+     */
+    private java.awt.datatransfer.Clipboard systemClipboard = null;
+
+    /**
+     * The image selection class.
+     */
+    private ImageSelection imageSelection;
+
+    /**
+     * ImageSelection is used to hold an image while on the clipboard.
+     */
+    private class ImageSelection implements Transferable {
+
+        /**
+         * Returns an array of DataFlavor objects indicating the flavors the
+         * data can be provided in. The array should be ordered according to
+         * preference for providing the data (from most richly descriptive to
+         * least descriptive).
+         *
+         * @return an array of data flavors in which this data can be
+         * transferred
+         */
+        public DataFlavor[] getTransferDataFlavors() {
+            return new DataFlavor[] { DataFlavor.imageFlavor };
+        }
+
+        /**
+         * Returns whether or not the specified data flavor is supported for
+         * this object.
+         *
+         * @param flavor the requested flavor for the data
+         * @return boolean indicating whether or not the data flavor is
+         * supported
+         */
+        public boolean isDataFlavorSupported(DataFlavor flavor) {
+            return DataFlavor.imageFlavor.equals(flavor);
+        }
+
+        /**
+         * Returns an object which represents the data to be transferred. The
+         * class of the object returned is defined by the representation
+         * class of the flavor.
+         *
+         * @param flavor the requested flavor for the data
+         * @throws IOException if the data is no longer available in the
+         * requested flavor.
+         * @throws UnsupportedFlavorException if the requested data flavor is
+         * not supported.
+         */
+        public Object getTransferData(DataFlavor flavor)
+                throws UnsupportedFlavorException, IOException {
+
+            if (!DataFlavor.imageFlavor.equals(flavor)) {
+                throw new UnsupportedFlavorException(flavor);
+            }
+            return image;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     */
+    public Clipboard() {
+        try {
+            systemClipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+        } catch (java.awt.HeadlessException e) {
+            // SQUASH
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Clipboard --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Copy an image to the clipboard.
+     *
+     * @param image image to copy
+     */
+    public void copyImage(final BufferedImage image) {
+        this.image = image;
+        if (systemClipboard != null) {
+            ImageSelection imageSelection = new ImageSelection();
+            systemClipboard.setContents(imageSelection, null);
+        }
+    }
+
+    /**
+     * Copy a text string to the clipboard.
+     *
+     * @param text string to copy
+     */
+    public void copyText(final String text) {
+        this.text = text;
+        if (systemClipboard != null) {
+            StringSelection stringSelection = new StringSelection(text);
+            systemClipboard.setContents(stringSelection, null);
+        }
+    }
+
+    /**
+     * Obtain an image from the clipboard.
+     *
+     * @return image from the clipboard, or null if no image is available
+     */
+    public BufferedImage pasteImage() {
+        if (systemClipboard != null) {
+            getClipboardImage();
+        }
+        return image;
+    }
+
+    /**
+     * Obtain a text string from the clipboard.
+     *
+     * @return text string from the clipboard, or null if no text is
+     * available
+     */
+    public String pasteText() {
+        if (systemClipboard != null) {
+            getClipboardText();
+        }
+        return text;
+    }
+
+    /**
+     * Returns true if the clipboard has an image.
+     *
+     * @return true if an image is available from the clipboard
+     */
+    public boolean isImage() {
+        if (image == null) {
+            getClipboardImage();
+        }
+        return (image != null);
+    }
+
+    /**
+     * Returns true if the clipboard has a text string.
+     *
+     * @return true if a text string is available from the clipboard
+     */
+    public boolean isText() {
+        if (text == null) {
+            getClipboardText();
+        }
+        return (text != null);
+    }
+
+    /**
+     * Returns true if the clipboard is empty.
+     *
+     * @return true if the clipboard is empty
+     */
+    public boolean isEmpty() {
+        return ((isText() == false) && (isImage() == false));
+    }
+
+    /**
+     * Copy image from the clipboard to text.
+     */
+    private void getClipboardImage() {
+        if (systemClipboard != null) {
+            Transferable contents = systemClipboard.getContents(null);
+            if (contents != null) {
+                if (contents.isDataFlavorSupported(DataFlavor.imageFlavor)) {
+                    try {
+                        Image img = (Image) contents.getTransferData(DataFlavor.imageFlavor);
+                        image = new BufferedImage(img.getWidth(null),
+                            img.getHeight(null), BufferedImage.TYPE_INT_ARGB);
+                        image.getGraphics().drawImage(img, 0, 0, null);
+                    } catch (IOException e) {
+                        // SQUASH
+                    } catch (UnsupportedFlavorException e) {
+                        // SQUASH
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Copy text string from the clipboard to text.
+     */
+    private void getClipboardText() {
+        if (systemClipboard != null) {
+            Transferable contents = systemClipboard.getContents(null);
+            if (contents != null) {
+                if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
+                    try {
+                        text = (String) contents.getTransferData(DataFlavor.stringFlavor);
+                    } catch (IOException e) {
+                        // SQUASH
+                    } catch (UnsupportedFlavorException e) {
+                        // SQUASH
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Clear whatever is on the local clipboard.  Note that this will not
+     * clear the system clipboard.
+     */
+    public void clear() {
+        image = null;
+        text = null;
+    }
+
+}
diff --git a/src/jexer/bits/Color.java b/src/jexer/bits/Color.java
new file mode 100644 (file)
index 0000000..4defed5
--- /dev/null
@@ -0,0 +1,272 @@
+/*
+ * 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.bits;
+
+/**
+ * A text cell color.
+ */
+public final class Color {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * SGR black value = 0.
+     */
+    private static final int SGRBLACK   = 0;
+
+    /**
+     * SGR red value = 1.
+     */
+    private static final int SGRRED     = 1;
+
+    /**
+     * SGR green value = 2.
+     */
+    private static final int SGRGREEN   = 2;
+
+    /**
+     * SGR yellow value = 3.
+     */
+    private static final int SGRYELLOW  = 3;
+
+    /**
+     * SGR blue value = 4.
+     */
+    private static final int SGRBLUE    = 4;
+
+    /**
+     * SGR magenta value = 5.
+     */
+    private static final int SGRMAGENTA = 5;
+
+    /**
+     * SGR cyan value = 6.
+     */
+    private static final int SGRCYAN    = 6;
+
+    /**
+     * SGR white value = 7.
+     */
+    private static final int SGRWHITE   = 7;
+
+    /**
+     * Black.  Bold + black = dark grey
+     */
+    public static final Color BLACK = new Color(SGRBLACK);
+
+    /**
+     * Red.
+     */
+    public static final Color RED = new Color(SGRRED);
+
+    /**
+     * Green.
+     */
+    public static final Color GREEN  = new Color(SGRGREEN);
+
+    /**
+     * Yellow.  Sometimes not-bold yellow is brown.
+     */
+    public static final Color YELLOW = new Color(SGRYELLOW);
+
+    /**
+     * Blue.
+     */
+    public static final Color BLUE = new Color(SGRBLUE);
+
+    /**
+     * Magenta (purple).
+     */
+    public static final Color MAGENTA = new Color(SGRMAGENTA);
+
+    /**
+     * Cyan (blue-green).
+     */
+    public static final Color CYAN = new Color(SGRCYAN);
+
+    /**
+     * White.
+     */
+    public static final Color WHITE = new Color(SGRWHITE);
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The color value.  Default is SGRWHITE.
+     */
+    private int value = SGRWHITE;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Private constructor used to make the static Color instances.
+     *
+     * @param value the integer Color value
+     */
+    private Color(final int value) {
+        this.value = value;
+    }
+
+    // ------------------------------------------------------------------------
+    // Color ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get color value.  Note that these deliberately match the color values
+     * of the ECMA-48 / ANSI X3.64 / VT100-ish SGR function ("ANSI colors").
+     *
+     * @return the value
+     */
+    public int getValue() {
+        return value;
+    }
+
+    /**
+     * Public constructor returns one of the static Color instances.
+     *
+     * @param colorName "red", "blue", etc.
+     * @return Color.RED, Color.BLUE, etc.
+     */
+    static Color getColor(final String colorName) {
+        String str = colorName.toLowerCase();
+
+        if (str.equals("black")) {
+            return Color.BLACK;
+        } else if (str.equals("white")) {
+            return Color.WHITE;
+        } else if (str.equals("red")) {
+            return Color.RED;
+        } else if (str.equals("cyan")) {
+            return Color.CYAN;
+        } else if (str.equals("green")) {
+            return Color.GREEN;
+        } else if (str.equals("magenta")) {
+            return Color.MAGENTA;
+        } else if (str.equals("blue")) {
+            return Color.BLUE;
+        } else if (str.equals("yellow")) {
+            return Color.YELLOW;
+        } else if (str.equals("brown")) {
+            return Color.YELLOW;
+        } else {
+            // Let unknown strings become white
+            return Color.WHITE;
+        }
+    }
+
+    /**
+     * Invert a color in the same way as (CGA/VGA color XOR 0x7).
+     *
+     * @return the inverted color
+     */
+    public Color invert() {
+        switch (value) {
+        case SGRBLACK:
+            return Color.WHITE;
+        case SGRWHITE:
+            return Color.BLACK;
+        case SGRRED:
+            return Color.CYAN;
+        case SGRCYAN:
+            return Color.RED;
+        case SGRGREEN:
+            return Color.MAGENTA;
+        case SGRMAGENTA:
+            return Color.GREEN;
+        case SGRBLUE:
+            return Color.YELLOW;
+        case SGRYELLOW:
+            return Color.BLUE;
+        default:
+            throw new IllegalArgumentException("Invalid Color value: " + value);
+        }
+    }
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another Color instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public boolean equals(final Object rhs) {
+        if (!(rhs instanceof Color)) {
+            return false;
+        }
+
+        Color that = (Color) rhs;
+        return (value == that.value);
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        return value;
+    }
+
+    /**
+     * Make human-readable description of this Color.
+     *
+     * @return displayable String "red", "blue", etc.
+     */
+    @Override
+    public String toString() {
+        switch (value) {
+        case SGRBLACK:
+            return "black";
+        case SGRWHITE:
+            return "white";
+        case SGRRED:
+            return "red";
+        case SGRCYAN:
+            return "cyan";
+        case SGRGREEN:
+            return "green";
+        case SGRMAGENTA:
+            return "magenta";
+        case SGRBLUE:
+            return "blue";
+        case SGRYELLOW:
+            return "yellow";
+        default:
+            throw new IllegalArgumentException("Invalid Color value: " + value);
+        }
+    }
+
+}
diff --git a/src/jexer/bits/ColorTheme.java b/src/jexer/bits/ColorTheme.java
new file mode 100644 (file)
index 0000000..3efce63
--- /dev/null
@@ -0,0 +1,738 @@
+/*
+ * 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.bits;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.StringTokenizer;
+import java.util.TreeMap;
+
+/**
+ * ColorTheme is a collection of colors keyed by string.  A default theme is
+ * also provided that matches the blue-and-white theme used by Turbo Vision.
+ */
+public class ColorTheme {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The current theme colors.
+     */
+    private SortedMap<String, CellAttributes> colors;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor sets the theme to the default.
+     */
+    public ColorTheme() {
+        colors = new TreeMap<String, CellAttributes>();
+        setDefaultTheme();
+    }
+
+    // ------------------------------------------------------------------------
+    // ColorTheme -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Retrieve the CellAttributes for a named theme color.
+     *
+     * @param name theme color name, e.g. "twindow.border"
+     * @return color associated with name, e.g. bold yellow on blue
+     */
+    public CellAttributes getColor(final String name) {
+        CellAttributes attr = colors.get(name);
+        return attr;
+    }
+
+    /**
+     * Retrieve all the names in the theme.
+     *
+     * @return a list of names
+     */
+    public List<String> getColorNames() {
+        Set<String> keys = colors.keySet();
+        List<String> names = new ArrayList<String>(keys.size());
+        names.addAll(keys);
+        return names;
+    }
+
+    /**
+     * Set the color for a named theme color.
+     *
+     * @param name theme color name, e.g. "twindow.border"
+     * @param color the new color to associate with name, e.g. bold yellow on
+     * blue
+     */
+    public void setColor(final String name, final CellAttributes color) {
+        colors.put(name, color);
+    }
+
+    /**
+     * Save the color theme mappings to an ASCII file.
+     *
+     * @param filename file to write to
+     * @throws IOException if the I/O fails
+     */
+    public void save(final String filename) throws IOException {
+        FileWriter file = new FileWriter(filename);
+        for (String key: colors.keySet()) {
+            CellAttributes color = getColor(key);
+            file.write(String.format("%s = %s\n", key, color));
+        }
+        file.close();
+    }
+
+    /**
+     * Read color theme mappings from an ASCII file.
+     *
+     * @param filename file to read from
+     * @throws IOException if the I/O fails
+     */
+    public void load(final String filename) throws IOException {
+        load(new FileReader(filename));
+    }
+
+    /**
+     * Set a color based on a text string.  Color text string is of the form:
+     * <code>[ bold ] [ blink ] { foreground on background }</code>
+     *
+     * @param key the color key string
+     * @param text the text string
+     */
+    public void setColorFromString(final String key, final String text) {
+        boolean bold = false;
+        boolean blink = false;
+        String foreColor;
+        String backColor;
+        String token;
+
+        StringTokenizer tokenizer = new StringTokenizer(text);
+        token = tokenizer.nextToken();
+
+        if (token.toLowerCase().equals("rgb:")) {
+            // Foreground
+            int foreColorRGB = -1;
+            try {
+                foreColorRGB = Integer.parseInt(tokenizer.nextToken(), 16);
+            } catch (NumberFormatException e) {
+                // Default to white on black
+                foreColorRGB = 0xFFFFFF;
+            }
+
+            // "on"
+            if (!tokenizer.nextToken().toLowerCase().equals("on")) {
+                // Invalid line.
+                return;
+            }
+
+            // Background
+            int backColorRGB = -1;
+            try {
+                backColorRGB = Integer.parseInt(tokenizer.nextToken(), 16);
+            } catch (NumberFormatException e) {
+                backColorRGB = 0;
+            }
+
+            CellAttributes color = new CellAttributes();
+            color.setForeColorRGB(foreColorRGB);
+            color.setBackColorRGB(backColorRGB);
+            colors.put(key, color);
+            return;
+        }
+
+        while (token.equals("bold")
+            || token.equals("bright")
+            || token.equals("blink")
+        ) {
+            if (token.equals("bold") || token.equals("bright")) {
+                bold = true;
+                token = tokenizer.nextToken();
+            }
+            if (token.equals("blink")) {
+                blink = true;
+                token = tokenizer.nextToken();
+            }
+        }
+
+        // What's left is "blah on blah"
+        foreColor = token.toLowerCase();
+
+        if (!tokenizer.nextToken().toLowerCase().equals("on")) {
+            // Invalid line.
+            return;
+        }
+        backColor = tokenizer.nextToken().toLowerCase();
+
+        CellAttributes color = new CellAttributes();
+        if (bold) {
+            color.setBold(true);
+        }
+        if (blink) {
+            color.setBlink(true);
+        }
+        color.setForeColor(Color.getColor(foreColor));
+        color.setBackColor(Color.getColor(backColor));
+        colors.put(key, color);
+    }
+
+    /**
+     * Read color theme mappings from a Reader.  The reader is closed at the
+     * end.
+     *
+     * @param reader the reader to read from
+     * @throws IOException if the I/O fails
+     */
+    public void load(final Reader reader) throws IOException {
+        BufferedReader bufferedReader = new BufferedReader(reader);
+        String line = bufferedReader.readLine();
+        for (; line != null; line = bufferedReader.readLine()) {
+            // Look for lines that resemble:
+            //     "key = blah on blah"
+            //     "key = bold blah on blah"
+            //     "key = blink bold blah on blah"
+            //     "key = bold blink blah on blah"
+            //     "key = blink blah on blah"
+            if (line.indexOf('=') == -1) {
+                // Invalid line.
+                continue;
+            }
+            String key = line.substring(0, line.indexOf('=')).trim();
+            String text = line.substring(line.indexOf('=') + 1);
+            setColorFromString(key, text);
+        }
+        // All done.
+        bufferedReader.close();
+    }
+
+    /**
+     * Sets to defaults that resemble the Borland IDE colors.
+     */
+    public void setDefaultTheme() {
+        CellAttributes color;
+
+        // TWindow border
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("twindow.border", color);
+
+        // TWindow background
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("twindow.background", color);
+
+        // TWindow border - inactive
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("twindow.border.inactive", color);
+
+        // TWindow background - inactive
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("twindow.background.inactive", color);
+
+        // TWindow border - modal
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.WHITE);
+        color.setBold(true);
+        colors.put("twindow.border.modal", color);
+
+        // TWindow background - modal
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("twindow.background.modal", color);
+
+        // TWindow border - modal + inactive
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(true);
+        colors.put("twindow.border.modal.inactive", color);
+
+        // TWindow background - modal + inactive
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("twindow.background.modal.inactive", color);
+
+        // TWindow border - during window movement - modal
+        color = new CellAttributes();
+        color.setForeColor(Color.GREEN);
+        color.setBackColor(Color.WHITE);
+        color.setBold(true);
+        colors.put("twindow.border.modal.windowmove", color);
+
+        // TWindow border - during window movement
+        color = new CellAttributes();
+        color.setForeColor(Color.GREEN);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("twindow.border.windowmove", color);
+
+        // TWindow background - during window movement
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("twindow.background.windowmove", color);
+
+        // TDesktop background
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tdesktop.background", color);
+
+        // TButton text
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.GREEN);
+        color.setBold(false);
+        colors.put("tbutton.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.CYAN);
+        color.setBackColor(Color.GREEN);
+        color.setBold(true);
+        colors.put("tbutton.active", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(true);
+        colors.put("tbutton.disabled", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.GREEN);
+        color.setBold(true);
+        colors.put("tbutton.mnemonic", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.GREEN);
+        color.setBold(true);
+        colors.put("tbutton.mnemonic.highlighted", color);
+
+        // TLabel text
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tlabel", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tlabel.mnemonic", color);
+
+        // TText text
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("ttext", color);
+
+        // TField text
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tfield.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tfield.active", color);
+
+        // TCheckBox
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tcheckbox.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLACK);
+        color.setBold(true);
+        colors.put("tcheckbox.active", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tcheckbox.mnemonic", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.BLACK);
+        color.setBold(true);
+        colors.put("tcheckbox.mnemonic.highlighted", color);
+
+        // TComboBox
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tcombobox.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tcombobox.active", color);
+
+        // TSpinner
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tspinner.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tspinner.active", color);
+
+        // TCalendar
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tcalendar.background", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tcalendar.day", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tcalendar.day.selected", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tcalendar.arrow", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tcalendar.title", color);
+
+        // TRadioButton
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tradiobutton.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLACK);
+        color.setBold(true);
+        colors.put("tradiobutton.active", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tradiobutton.mnemonic", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.BLACK);
+        color.setBold(true);
+        colors.put("tradiobutton.mnemonic.highlighted", color);
+
+        // TRadioGroup
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tradiogroup.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tradiogroup.active", color);
+
+        // TMenu
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tmenu", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.GREEN);
+        color.setBold(false);
+        colors.put("tmenu.highlighted", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tmenu.mnemonic", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.GREEN);
+        color.setBold(false);
+        colors.put("tmenu.mnemonic.highlighted", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(true);
+        colors.put("tmenu.disabled", color);
+
+        // TProgressBar
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("tprogressbar.complete", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tprogressbar.incomplete", color);
+
+        // THScroller / TVScroller
+        color = new CellAttributes();
+        color.setForeColor(Color.CYAN);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tscroller.bar", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tscroller.arrows", color);
+
+        // TTreeView
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("ttreeview", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.GREEN);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        colors.put("ttreeview.expandbutton", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("ttreeview.selected", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("ttreeview.unreadable", color);
+        color = new CellAttributes();
+        // color.setForeColor(Color.BLACK);
+        // color.setBackColor(Color.BLUE);
+        // color.setBold(true);
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("ttreeview.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("ttreeview.selected.inactive", color);
+
+        // TList
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tlist", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tlist.selected", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("tlist.unreadable", color);
+        color = new CellAttributes();
+        // color.setForeColor(Color.BLACK);
+        // color.setBackColor(Color.BLUE);
+        // color.setBold(true);
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tlist.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tlist.selected.inactive", color);
+
+        // TStatusBar
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tstatusbar.text", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.RED);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("tstatusbar.button", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("tstatusbar.selected", color);
+
+        // TEditor
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("teditor", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("teditor.selected", color);
+
+        // TTable
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("ttable.inactive", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.CYAN);
+        color.setBold(false);
+        colors.put("ttable.active", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.CYAN);
+        color.setBold(true);
+        colors.put("ttable.selected", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLACK);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("ttable.label", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.BLUE);
+        color.setBackColor(Color.WHITE);
+        color.setBold(false);
+        colors.put("ttable.label.selected", color);
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(false);
+        colors.put("ttable.border", color);
+
+        // TSplitPane
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        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);
+
+    }
+
+    /**
+     * Make human-readable description of this Cell.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return colors.toString();
+    }
+
+}
diff --git a/src/jexer/bits/GraphicsChars.java b/src/jexer/bits/GraphicsChars.java
new file mode 100644 (file)
index 0000000..58be231
--- /dev/null
@@ -0,0 +1,161 @@
+/*
+ * 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.bits;
+
+/**
+ * This class contains a collection of special characters used by the
+ * windowing system and the mappings from CP437 to Unicode.
+ */
+public final class GraphicsChars {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The CP437 to Unicode translation map.
+     */
+    public static final char [] CP437 = {
+        '\u2007', '\u263A', '\u263B', '\u2665',
+        '\u2666', '\u2663', '\u2660', '\u2022',
+        '\u25D8', '\u25CB', '\u25D9', '\u2642',
+        '\u2640', '\u266A', '\u266B', '\u263C',
+        // Terminus has 25B6 and 25C0 here, which I believe are better
+        // Unicode equivalents anyway.
+        // '\u25BA', '\u25C4', '\u2195', '\u203C',
+        '\u25B6', '\u25C0', '\u2195', '\u203C',
+        '\u00B6', '\u00A7', '\u25AC', '\u21A8',
+        '\u2191', '\u2193', '\u2192', '\u2190',
+        '\u221F', '\u2194', '\u25B2', '\u25BC',
+        '\u0020', '\u0021', '\"', '\u0023',
+        '\u0024', '\u0025', '\u0026', '\'',
+        '\u0028', '\u0029', '\u002a', '\u002b',
+        '\u002c', '\u002d', '\u002e', '\u002f',
+        '\u0030', '\u0031', '\u0032', '\u0033',
+        '\u0034', '\u0035', '\u0036', '\u0037',
+        '\u0038', '\u0039', '\u003a', '\u003b',
+        '\u003c', '\u003d', '\u003e', '\u003f',
+        '\u0040', '\u0041', '\u0042', '\u0043',
+        '\u0044', '\u0045', '\u0046', '\u0047',
+        '\u0048', '\u0049', '\u004a', '\u004b',
+        '\u004c', '\u004d', '\u004e', '\u004f',
+        '\u0050', '\u0051', '\u0052', '\u0053',
+        '\u0054', '\u0055', '\u0056', '\u0057',
+        '\u0058', '\u0059', '\u005a', '\u005b',
+        '\\', '\u005d', '\u005e', '\u005f',
+        '\u0060', '\u0061', '\u0062', '\u0063',
+        '\u0064', '\u0065', '\u0066', '\u0067',
+        '\u0068', '\u0069', '\u006a', '\u006b',
+        '\u006c', '\u006d', '\u006e', '\u006f',
+        '\u0070', '\u0071', '\u0072', '\u0073',
+        '\u0074', '\u0075', '\u0076', '\u0077',
+        '\u0078', '\u0079', '\u007a', '\u007b',
+        '\u007c', '\u007d', '\u007e', '\u2302',
+        '\u00c7', '\u00fc', '\u00e9', '\u00e2',
+        '\u00e4', '\u00e0', '\u00e5', '\u00e7',
+        '\u00ea', '\u00eb', '\u00e8', '\u00ef',
+        '\u00ee', '\u00ec', '\u00c4', '\u00c5',
+        '\u00c9', '\u00e6', '\u00c6', '\u00f4',
+        '\u00f6', '\u00f2', '\u00fb', '\u00f9',
+        '\u00ff', '\u00d6', '\u00dc', '\u00a2',
+        '\u00a3', '\u00a5', '\u20a7', '\u0192',
+        '\u00e1', '\u00ed', '\u00f3', '\u00fa',
+        '\u00f1', '\u00d1', '\u00aa', '\u00ba',
+        '\u00bf', '\u2310', '\u00ac', '\u00bd',
+        '\u00bc', '\u00a1', '\u00ab', '\u00bb',
+        '\u2591', '\u2592', '\u2593', '\u2502',
+        '\u2524', '\u2561', '\u2562', '\u2556',
+        '\u2555', '\u2563', '\u2551', '\u2557',
+        '\u255d', '\u255c', '\u255b', '\u2510',
+        '\u2514', '\u2534', '\u252c', '\u251c',
+        '\u2500', '\u253c', '\u255e', '\u255f',
+        '\u255a', '\u2554', '\u2569', '\u2566',
+        '\u2560', '\u2550', '\u256c', '\u2567',
+        '\u2568', '\u2564', '\u2565', '\u2559',
+        '\u2558', '\u2552', '\u2553', '\u256b',
+        '\u256a', '\u2518', '\u250c', '\u2588',
+        '\u2584', '\u258c', '\u2590', '\u2580',
+        '\u03b1', '\u00df', '\u0393', '\u03c0',
+        '\u03a3', '\u03c3', '\u00b5', '\u03c4',
+        '\u03a6', '\u0398', '\u03a9', '\u03b4',
+        '\u221e', '\u03c6', '\u03b5', '\u2229',
+        '\u2261', '\u00b1', '\u2265', '\u2264',
+        '\u2320', '\u2321', '\u00f7', '\u2248',
+        '\u00b0', '\u2219', '\u00b7', '\u221a',
+        '\u207f', '\u00b2', '\u25a0', '\u00a0'
+    };
+
+    public static final char HATCH                      = CP437[0xB0];
+    public static final char DOUBLE_BAR                 = CP437[0xCD];
+    public static final char BOX                        = CP437[0xFE];
+    public static final char CHECK                      = CP437[0xFB];
+    public static final char TRIPLET                    = CP437[0xF0];
+    public static final char OMEGA                      = CP437[0xEA];
+    public static final char PI                         = CP437[0xE3];
+    public static final char UPARROW                    = CP437[0x18];
+    public static final char DOWNARROW                  = CP437[0x19];
+    public static final char RIGHTARROW                 = CP437[0x1A];
+    public static final char LEFTARROW                  = CP437[0x1B];
+    public static final char SINGLE_BAR                 = CP437[0xC4];
+    public static final char BACK_ARROWHEAD             = CP437[0x11];
+    public static final char LRCORNER                   = CP437[0xD9];
+    public static final char URCORNER                   = CP437[0xBF];
+    public static final char LLCORNER                   = CP437[0xC0];
+    public static final char ULCORNER                   = CP437[0xDA];
+    public static final char DEGREE                     = CP437[0xF8];
+    public static final char PLUSMINUS                  = CP437[0xF1];
+    public static final char WINDOW_TOP                 = CP437[0xCD];
+    public static final char WINDOW_LEFT_TOP            = CP437[0xD5];
+    public static final char WINDOW_RIGHT_TOP           = CP437[0xB8];
+    public static final char WINDOW_SIDE                = CP437[0xB3];
+    public static final char WINDOW_LEFT_BOTTOM         = CP437[0xD4];
+    public static final char WINDOW_RIGHT_BOTTOM        = CP437[0xBE];
+    public static final char WINDOW_LEFT_TEE            = CP437[0xC6];
+    public static final char WINDOW_RIGHT_TEE           = CP437[0xB5];
+    public static final char WINDOW_SIDE_DOUBLE         = CP437[0xBA];
+    public static final char WINDOW_LEFT_TOP_DOUBLE     = CP437[0xC9];
+    public static final char WINDOW_RIGHT_TOP_DOUBLE    = CP437[0xBB];
+    public static final char WINDOW_LEFT_BOTTOM_DOUBLE  = CP437[0xC8];
+    public static final char WINDOW_RIGHT_BOTTOM_DOUBLE = CP437[0xBC];
+    public static final char VERTICAL_BAR               = CP437[0xB3];
+    public static final char OCTOSTAR                   = CP437[0x0F];
+    public static final char DOWNARROWLEFT              = CP437[0xDD];
+    public static final char DOWNARROWRIGHT             = CP437[0xDE];
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Private constructor prevents accidental creation of this class.
+     */
+    private GraphicsChars() {
+    }
+
+}
diff --git a/src/jexer/bits/MnemonicString.java b/src/jexer/bits/MnemonicString.java
new file mode 100644 (file)
index 0000000..58575b5
--- /dev/null
@@ -0,0 +1,154 @@
+/*
+ * 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.bits;
+
+/**
+ * MnemonicString is used to render a string like "&amp;File" into a
+ * highlighted 'F' and the rest of 'ile'.  To insert a literal '&amp;', use
+ * two '&amp;&amp;' characters, e.g. "&amp;File &amp;&amp; Stuff" would be
+ * "File &amp; Stuff" with the first 'F' highlighted.
+ */
+public class MnemonicString {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Keyboard shortcut to activate this item.
+     */
+    private int shortcut;
+
+    /**
+     * Location of the highlighted character.
+     */
+    private int shortcutIdx = -1;
+
+    /**
+     * Screen location of the highlighted character (number of text cells
+     * required to display from the beginning to shortcutIdx).
+     */
+    private int screenShortcutIdx = -1;
+
+    /**
+     * The raw (uncolored) string.
+     */
+    private String rawLabel;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param label widget label or title.  Label must contain a keyboard
+     * shortcut, denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+     */
+    public MnemonicString(final String label) {
+
+        // Setup the menu shortcut
+        StringBuilder newLabel = new StringBuilder();
+        boolean foundAmp = false;
+        boolean foundShortcut = false;
+        int scanShortcutIdx = 0;
+        int scanScreenShortcutIdx = 0;
+        for (int i = 0; i < label.length();) {
+            int c = label.codePointAt(i);
+            i += Character.charCount(c);
+
+            if (c == '&') {
+                if (foundAmp) {
+                    newLabel.append('&');
+                    scanShortcutIdx++;
+                    scanScreenShortcutIdx++;
+                } else {
+                    foundAmp = true;
+                }
+            } else {
+                newLabel.append(Character.toChars(c));
+                if (foundAmp) {
+                    if (!foundShortcut) {
+                        shortcut = c;
+                        foundAmp = false;
+                        foundShortcut = true;
+                        shortcutIdx = scanShortcutIdx;
+                        screenShortcutIdx = scanScreenShortcutIdx;
+                    }
+                } else {
+                    scanShortcutIdx++;
+                    scanScreenShortcutIdx += StringUtils.width(c);
+                }
+            }
+        }
+        this.rawLabel = newLabel.toString();
+    }
+
+    // ------------------------------------------------------------------------
+    // MnemonicString ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the keyboard shortcut character.
+     *
+     * @return the highlighted character
+     */
+    public int getShortcut() {
+        return shortcut;
+    }
+
+    /**
+     * Get location of the highlighted character.
+     *
+     * @return location of the highlighted character
+     */
+    public int getShortcutIdx() {
+        return shortcutIdx;
+    }
+
+    /**
+     * Get the screen location of the highlighted character.
+     *
+     * @return the number of text cells required to display from the
+     * beginning of the label to shortcutIdx
+     */
+    public int getScreenShortcutIdx() {
+        return screenShortcutIdx;
+    }
+
+    /**
+     * Get the raw (uncolored) string.
+     *
+     * @return the raw (uncolored) string
+     */
+    public String getRawLabel() {
+        return rawLabel;
+    }
+
+}
diff --git a/src/jexer/bits/StringUtils.java b/src/jexer/bits/StringUtils.java
new file mode 100644 (file)
index 0000000..d33f71f
--- /dev/null
@@ -0,0 +1,745 @@
+/*
+ * 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.bits;
+
+import java.util.List;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+/**
+ * StringUtils contains methods to:
+ *
+ *    - Convert one or more long lines of strings into justified text
+ *      paragraphs.
+ *
+ *    - Unescape C0 control codes.
+ *
+ *    - Read/write a line of RFC4180 comma-separated values strings to/from a
+ *      list of strings.
+ *
+ *    - Compute number of visible text cells for a given Unicode codepoint or
+ *      string.
+ *
+ *    - Convert bytes to and from base-64 encoding.
+ */
+public class StringUtils {
+
+    /**
+     * Left-justify a string into a list of lines.
+     *
+     * @param str the string
+     * @param n the maximum number of characters in a line
+     * @return the list of lines
+     */
+    public static List<String> left(final String str, final int n) {
+        List<String> result = new ArrayList<String>();
+
+        /*
+         * General procedure:
+         *
+         *   1. Split on '\n' into paragraphs.
+         *
+         *   2. Scan each line, noting the position of the last
+         *      beginning-of-a-word.
+         *
+         *   3. Chop at the last #2 if the next beginning-of-a-word exceeds
+         *      n.
+         *
+         *   4. Return the lines.
+         */
+
+        String [] rawLines = str.split("\n");
+        for (int i = 0; i < rawLines.length; i++) {
+            StringBuilder line = new StringBuilder();
+            StringBuilder word = new StringBuilder();
+            boolean inWord = false;
+            for (int j = 0; j < rawLines[i].length(); j++) {
+                char ch = rawLines[i].charAt(j);
+                if ((ch == ' ') || (ch == '\t')) {
+                    if (inWord == true) {
+                        // We have just transitioned from a word to
+                        // whitespace.  See if we have enough space to add
+                        // the word to the line.
+                        if (width(word.toString()) + width(line.toString()) > n) {
+                            // This word will exceed the line length.  Wrap
+                            // at it instead.
+                            result.add(line.toString());
+                            line = new StringBuilder();
+                        }
+                        if ((word.toString().startsWith(" "))
+                            && (width(line.toString()) == 0)
+                        ) {
+                            line.append(word.substring(1));
+                        } else {
+                            line.append(word);
+                        }
+                        word = new StringBuilder();
+                        word.append(ch);
+                        inWord = false;
+                    } else {
+                        // We are in the whitespace before another word.  Do
+                        // nothing.
+                    }
+                } else {
+                    if (inWord == true) {
+                        // We are appending to a word.
+                        word.append(ch);
+                    } else {
+                        // We have transitioned from whitespace to a word.
+                        word.append(ch);
+                        inWord = true;
+                    }
+                }
+            } // for (int j = 0; j < rawLines[i].length(); j++)
+
+            if (width(word.toString()) + width(line.toString()) > n) {
+                // This word will exceed the line length.  Wrap at it
+                // instead.
+                result.add(line.toString());
+                line = new StringBuilder();
+            }
+            if ((word.toString().startsWith(" "))
+                && (width(line.toString()) == 0)
+            ) {
+                line.append(word.substring(1));
+            } else {
+                line.append(word);
+            }
+            result.add(line.toString());
+        } // for (int i = 0; i < rawLines.length; i++) {
+
+        return result;
+    }
+
+    /**
+     * Right-justify a string into a list of lines.
+     *
+     * @param str the string
+     * @param n the maximum number of characters in a line
+     * @return the list of lines
+     */
+    public static List<String> right(final String str, final int n) {
+        List<String> result = new ArrayList<String>();
+
+        /*
+         * Same as left(), but preceed each line with spaces to make it n
+         * chars long.
+         */
+        List<String> lines = left(str, n);
+        for (String line: lines) {
+            StringBuilder sb = new StringBuilder();
+            for (int i = 0; i < n - width(line); i++) {
+                sb.append(' ');
+            }
+            sb.append(line);
+            result.add(sb.toString());
+        }
+
+        return result;
+    }
+
+    /**
+     * Center a string into a list of lines.
+     *
+     * @param str the string
+     * @param n the maximum number of characters in a line
+     * @return the list of lines
+     */
+    public static List<String> center(final String str, final int n) {
+        List<String> result = new ArrayList<String>();
+
+        /*
+         * Same as left(), but preceed/succeed each line with spaces to make
+         * it n chars long.
+         */
+        List<String> lines = left(str, n);
+        for (String line: lines) {
+            StringBuilder sb = new StringBuilder();
+            int l = (n - width(line)) / 2;
+            int r = n - width(line) - l;
+            for (int i = 0; i < l; i++) {
+                sb.append(' ');
+            }
+            sb.append(line);
+            for (int i = 0; i < r; i++) {
+                sb.append(' ');
+            }
+            result.add(sb.toString());
+        }
+
+        return result;
+    }
+
+    /**
+     * Fully-justify a string into a list of lines.
+     *
+     * @param str the string
+     * @param n the maximum number of characters in a line
+     * @return the list of lines
+     */
+    public static List<String> full(final String str, final int n) {
+        List<String> result = new ArrayList<String>();
+
+        /*
+         * Same as left(), but insert spaces between words to make each line
+         * n chars long.  The "algorithm" here is pretty dumb: it performs a
+         * split on space and then re-inserts multiples of n between words.
+         */
+        List<String> lines = left(str, n);
+        for (int lineI = 0; lineI < lines.size() - 1; lineI++) {
+            String line = lines.get(lineI);
+            String [] words = line.split(" ");
+            if (words.length > 1) {
+                int charCount = 0;
+                for (int i = 0; i < words.length; i++) {
+                    charCount += words[i].length();
+                }
+                int spaceCount = n - charCount;
+                int q = spaceCount / (words.length - 1);
+                int r = spaceCount % (words.length - 1);
+                StringBuilder sb = new StringBuilder();
+                for (int i = 0; i < words.length - 1; i++) {
+                    sb.append(words[i]);
+                    for (int j = 0; j < q; j++) {
+                        sb.append(' ');
+                    }
+                    if (r > 0) {
+                        sb.append(' ');
+                        r--;
+                    }
+                }
+                for (int j = 0; j < r; j++) {
+                    sb.append(' ');
+                }
+                sb.append(words[words.length - 1]);
+                result.add(sb.toString());
+            } else {
+                result.add(line);
+            }
+        }
+        if (lines.size() > 0) {
+            result.add(lines.get(lines.size() - 1));
+        }
+
+        return result;
+    }
+
+    /**
+     * Convert raw strings into escaped strings that be splatted on the
+     * screen.
+     *
+     * @param str the string
+     * @return a string that can be passed into Screen.putStringXY()
+     */
+    public static String unescape(final String str) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < str.length(); i++) {
+            char ch = str.charAt(i);
+            if ((ch < 0x20) || (ch == 0x7F)) {
+                switch (ch) {
+                case '\b':
+                    sb.append("\\b");
+                    continue;
+                case '\f':
+                    sb.append("\\f");
+                    continue;
+                case '\n':
+                    sb.append("\\n");
+                    continue;
+                case '\r':
+                    sb.append("\\r");
+                    continue;
+                case '\t':
+                    sb.append("\\t");
+                    continue;
+                case 0x7f:
+                    sb.append("^?");
+                    continue;
+                default:
+                    sb.append(' ');
+                    continue;
+                }
+            }
+            sb.append(ch);
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Read a line of RFC4180 comma-separated values (CSV) into a list of
+     * strings.
+     *
+     * @param line the CSV line, with or without without line terminators
+     * @return the list of strings
+     */
+    public static List<String> fromCsv(final String line) {
+        List<String> result = new ArrayList<String>();
+
+        StringBuilder str = new StringBuilder();
+        boolean quoted = false;
+        boolean fieldQuoted = false;
+
+        for (int i = 0; i < line.length(); i++) {
+            char ch = line.charAt(i);
+
+            /*
+            System.err.println("ch '" + ch + "' str '" + str + "' " +
+                " fieldQuoted " + fieldQuoted + " quoted " + quoted);
+             */
+
+            if (ch == ',') {
+                if (fieldQuoted && quoted) {
+                    // Terminating a quoted field.
+                    result.add(str.toString());
+                    str = new StringBuilder();
+                    quoted = false;
+                    fieldQuoted = false;
+                } else if (fieldQuoted) {
+                    // Still waiting to see the terminating quote for this
+                    // field.
+                    str.append(ch);
+                } else if (quoted) {
+                    // An unmatched double-quote and comma.  This should be
+                    // an invalid sequence.  We will treat it as a quote
+                    // terminating the field.
+                    str.append('\"');
+                    result.add(str.toString());
+                    str = new StringBuilder();
+                    quoted = false;
+                    fieldQuoted = false;
+                } else {
+                    // A field separator.
+                    result.add(str.toString());
+                    str = new StringBuilder();
+                    quoted = false;
+                    fieldQuoted = false;
+                }
+                continue;
+            }
+
+            if (ch == '\"') {
+                if ((str.length() == 0) && (!fieldQuoted)) {
+                    // The opening quote to a quoted field.
+                    fieldQuoted = true;
+                } else if (quoted) {
+                    // This is a double-quote.
+                    str.append('\"');
+                    quoted = false;
+                } else {
+                    // This is the beginning of a quote.
+                    quoted = true;
+                }
+                continue;
+            }
+
+            // Normal character, pass it on.
+            str.append(ch);
+        }
+
+        // Include the final field.
+        result.add(str.toString());
+
+        return result;
+    }
+
+    /**
+     * Write a list of strings to on line of RFC4180 comma-separated values
+     * (CSV).
+     *
+     * @param list the list of strings
+     * @return the CSV line, without any line terminators
+     */
+    public static String toCsv(final List<String> list) {
+        StringBuilder result = new StringBuilder();
+        int i = 0;
+        for (String str: list) {
+
+            if (!str.contains("\"") && !str.contains(",")) {
+                // Just append the string with a comma.
+                result.append(str);
+            } else if (!str.contains("\"") && str.contains(",")) {
+                // Contains commas, but no quotes.  Just double-quote it.
+                result.append("\"");
+                result.append(str);
+                result.append("\"");
+            } else if (str.contains("\"")) {
+                // Contains quotes and maybe commas.  Double-quote it and
+                // replace quotes inside.
+                result.append("\"");
+                for (int j = 0; j < str.length(); j++) {
+                    char ch = str.charAt(j);
+                    result.append(ch);
+                    if (ch == '\"') {
+                        result.append("\"");
+                    }
+                }
+                result.append("\"");
+            }
+
+            if (i < list.size() - 1) {
+                result.append(",");
+            }
+            i++;
+        }
+        return result.toString();
+    }
+
+    /**
+     * Determine display width of a Unicode code point.
+     *
+     * @param ch the code point, can be char
+     * @return the number of text cell columns required to display this code
+     * point, one of 0, 1, or 2
+     */
+    public static int width(final int ch) {
+        /*
+         * This routine is a modified version of mk_wcwidth() available
+         * at: http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
+         *
+         * The combining characters list has been omitted from this
+         * implementation.  Hopefully no users will be impacted.
+         */
+
+        // 8-bit control characters: width 0
+        if (ch == 0) {
+            return 0;
+        }
+        if ((ch < 32) || ((ch >= 0x7f) && (ch < 0xa0))) {
+            return 0;
+        }
+
+        // All others: either 1 or 2
+        if ((ch >= 0x1100)
+            && ((ch <= 0x115f)
+                // Hangul Jamo init. consonants
+                || (ch == 0x2329)
+                || (ch == 0x232a)
+                // CJK ... Yi
+                || ((ch >= 0x2e80) && (ch <= 0xa4cf) && (ch != 0x303f))
+                // Hangul Syllables
+                || ((ch >= 0xac00) && (ch <= 0xd7a3))
+                // CJK Compatibility Ideographs
+                || ((ch >= 0xf900) && (ch <= 0xfaff))
+                // Vertical forms
+                || ((ch >= 0xfe10) && (ch <= 0xfe19))
+                // CJK Compatibility Forms
+                || ((ch >= 0xfe30) && (ch <= 0xfe6f))
+                // Fullwidth Forms
+                || ((ch >= 0xff00) && (ch <= 0xff60))
+                || ((ch >= 0xffe0) && (ch <= 0xffe6))
+                || ((ch >= 0x20000) && (ch <= 0x2fffd))
+                || ((ch >= 0x30000) && (ch <= 0x3fffd))
+                // emoji
+                || ((ch >= 0x1f004) && (ch <= 0x1fffd))
+            )
+        ) {
+            return 2;
+        }
+        return 1;
+    }
+
+    /**
+     * Determine display width of a string.  This ASSUMES that no characters
+     * are combining.  Hopefully no users will be impacted.
+     *
+     * @param str the string
+     * @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);
+            n += width(ch);
+            i += Character.charCount(ch);
+        }
+        return n;
+    }
+
+    /**
+     * Check if character is in the CJK range.
+     *
+     * @param ch character to check
+     * @return true if this character is in the CJK range
+     */
+    public static boolean isCjk(final int ch) {
+        return ((ch >= 0x2e80) && (ch <= 0x9fff));
+    }
+
+    /**
+     * Check if character is in the emoji range.
+     *
+     * @param ch character to check
+     * @return true if this character is in the emoji range
+     */
+    public static boolean isEmoji(final int ch) {
+        return ((ch >= 0x1f004) && (ch <= 0x1fffd));
+    }
+
+    // ------------------------------------------------------------------------
+    // Base64 -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /*
+     * The Base64 encoder/decoder below is provided to support JDK 1.6 - JDK
+     * 11.  It was taken from https://sourceforge.net/projects/migbase64/
+     *
+     * The following changes were made:
+     *
+     * - Code has been indented and long lines cut to fit within 80 columns.
+     *
+     * - Char, String, and "fast" byte functions removed.  byte versions
+     *   retained and called toBase64()/fromBase64().
+     *
+     * - Enclosing braces added to blocks.
+     */
+
+    /**
+     * A very fast and memory efficient class to encode and decode to and
+     * from BASE64 in full accordance with RFC 2045.<br><br> On Windows XP
+     * sp1 with 1.4.2_04 and later ;), this encoder and decoder is about 10
+     * times faster on small arrays (10 - 1000 bytes) and 2-3 times as fast
+     * on larger arrays (10000 - 1000000 bytes) compared to
+     * <code>sun.misc.Encoder()/Decoder()</code>.<br><br>
+     *
+     * On byte arrays the encoder is about 20% faster than Jakarta Commons
+     * Base64 Codec for encode and about 50% faster for decoding large
+     * arrays. This implementation is about twice as fast on very small
+     * arrays (&lt 30 bytes). If source/destination is a <code>String</code>
+     * this version is about three times as fast due to the fact that the
+     * Commons Codec result has to be recoded to a <code>String</code> from
+     * <code>byte[]</code>, which is very expensive.<br><br>
+     *
+     * This encode/decode algorithm doesn't create any temporary arrays as
+     * many other codecs do, it only allocates the resulting array. This
+     * produces less garbage and it is possible to handle arrays twice as
+     * large as algorithms that create a temporary array. (E.g. Jakarta
+     * Commons Codec). It is unknown whether Sun's
+     * <code>sun.misc.Encoder()/Decoder()</code> produce temporary arrays but
+     * since performance is quite low it probably does.<br><br>
+     *
+     * The encoder produces the same output as the Sun one except that the
+     * Sun's encoder appends a trailing line separator if the last character
+     * isn't a pad. Unclear why but it only adds to the length and is
+     * probably a side effect. Both are in conformance with RFC 2045
+     * though.<br> Commons codec seem to always att a trailing line
+     * separator.<br><br>
+     *
+     * <b>Note!</b> The encode/decode method pairs (types) come in three
+     * versions with the <b>exact</b> same algorithm and thus a lot of code
+     * redundancy. This is to not create any temporary arrays for transcoding
+     * to/from different format types. The methods not used can simply be
+     * commented out.<br><br>
+     *
+     * There is also a "fast" version of all decode methods that works the
+     * same way as the normal ones, but har a few demands on the decoded
+     * input. Normally though, these fast verions should be used if the
+     * source if the input is known and it hasn't bee tampered with.<br><br>
+     *
+     * If you find the code useful or you find a bug, please send me a note
+     * at base64 @ miginfocom . com.
+     *
+     * Licence (BSD):
+     * ==============
+     *
+     * Copyright (c) 2004, Mikael Grev, MiG InfoCom AB. (base64 @ miginfocom
+     * . com) All rights reserved.
+     *
+     * Redistribution and use in source and binary forms, with or without
+     * modification, are permitted provided that the following conditions are
+     * met: Redistributions of source code must retain the above copyright
+     * notice, this list of conditions and the following disclaimer.
+     * Redistributions in binary form must reproduce the above copyright
+     * notice, this list of conditions and the following disclaimer in the
+     * documentation and/or other materials provided with the distribution.
+     * Neither the name of the MiG InfoCom AB nor the names of its
+     * contributors may be used to endorse or promote products derived from
+     * this software without specific prior written permission.
+     *
+     * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+     * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+     * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+     * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
+     * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+     * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+     * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+     * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+     * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+     * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+     * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+     *
+     * @version 2.2
+     * @author Mikael Grev
+     *         Date: 2004-aug-02
+     *         Time: 11:31:11
+     */
+
+    private static final char[] CA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
+    private static final int[] IA = new int[256];
+    static {
+        Arrays.fill(IA, -1);
+        for (int i = 0, iS = CA.length; i < iS; i++) {
+            IA[CA[i]] = i;
+        }
+        IA['='] = 0;
+    }
+
+    /**
+     * Encodes a raw byte array into a BASE64 <code>byte[]</code>
+     * representation i accordance with RFC 2045.
+     * @param sArr The bytes to convert. If <code>null</code> or length 0
+     * an empty array will be returned.
+     * @return A BASE64 encoded array. Never <code>null</code>.
+     */
+    public final static String toBase64(byte[] sArr) {
+        // Check special case
+        int sLen = sArr != null ? sArr.length : 0;
+        if (sLen == 0) {
+            return "";
+        }
+
+        final boolean lineSep = true;
+
+        int eLen = (sLen / 3) * 3;                              // Length of even 24-bits.
+        int cCnt = ((sLen - 1) / 3 + 1) << 2;                   // Returned character count
+        int dLen = cCnt + (lineSep ? (cCnt - 1) / 76 << 1 : 0); // Length of returned array
+        byte[] dArr = new byte[dLen];
+
+        // Encode even 24-bits
+        for (int s = 0, d = 0, cc = 0; s < eLen;) {
+            // Copy next three bytes into lower 24 bits of int, paying
+            // attension to sign.
+            int i = (sArr[s++] & 0xff) << 16 | (sArr[s++] & 0xff) << 8 | (sArr[s++] & 0xff);
+
+            // Encode the int into four chars
+            dArr[d++] = (byte) CA[(i >>> 18) & 0x3f];
+            dArr[d++] = (byte) CA[(i >>> 12) & 0x3f];
+            dArr[d++] = (byte) CA[(i >>> 6) & 0x3f];
+            dArr[d++] = (byte) CA[i & 0x3f];
+
+            // Add optional line separator
+            if (lineSep && ++cc == 19 && d < dLen - 2) {
+                dArr[d++] = '\r';
+                dArr[d++] = '\n';
+                cc = 0;
+            }
+        }
+
+        // Pad and encode last bits if source isn't an even 24 bits.
+        int left = sLen - eLen; // 0 - 2.
+        if (left > 0) {
+            // Prepare the int
+            int i = ((sArr[eLen] & 0xff) << 10) | (left == 2 ? ((sArr[sLen - 1] & 0xff) << 2) : 0);
+
+            // Set last four chars
+            dArr[dLen - 4] = (byte) CA[i >> 12];
+            dArr[dLen - 3] = (byte) CA[(i >>> 6) & 0x3f];
+            dArr[dLen - 2] = left == 2 ? (byte) CA[i & 0x3f] : (byte) '=';
+            dArr[dLen - 1] = '=';
+        }
+        try {
+            return new String(dArr, "UTF-8");
+        } catch (java.io.UnsupportedEncodingException e) {
+            throw new IllegalArgumentException(e);
+        }
+
+    }
+
+    /**
+     * Decodes a BASE64 encoded byte array. All illegal characters will
+     * be ignored and can handle both arrays with and without line
+     * separators.
+     * @param sArr The source array. Length 0 will return an empty
+     * array. <code>null</code> will throw an exception.
+     * @return The decoded array of bytes. May be of length 0. Will be
+     * <code>null</code> if the legal characters (including '=') isn't
+     * divideable by 4. (I.e. definitely corrupted).
+     */
+    public final static byte[] fromBase64(byte[] sArr) {
+        // Check special case
+        int sLen = sArr.length;
+
+        // Count illegal characters (including '\r', '\n') to know what
+        // size the returned array will be, so we don't have to
+        // reallocate & copy it later.
+        int sepCnt = 0; // Number of separator characters. (Actually illegal characters, but that's a bonus...)
+        for (int i = 0; i < sLen; i++) {
+            // If input is "pure" (I.e. no line separators or illegal chars)
+            // base64 this loop can be commented out.
+            if (IA[sArr[i] & 0xff] < 0) {
+                sepCnt++;
+            }
+        }
+
+        // Check so that legal chars (including '=') are evenly
+        // divideable by 4 as specified in RFC 2045.
+        if ((sLen - sepCnt) % 4 != 0) {
+            return null;
+        }
+
+        int pad = 0;
+        for (int i = sLen; i > 1 && IA[sArr[--i] & 0xff] <= 0;) {
+            if (sArr[i] == '=') {
+                pad++;
+            }
+        }
+
+        int len = ((sLen - sepCnt) * 6 >> 3) - pad;
+
+        byte[] dArr = new byte[len];       // Preallocate byte[] of exact length
+
+        for (int s = 0, d = 0; d < len;) {
+            // Assemble three bytes into an int from four "valid" characters.
+            int i = 0;
+            for (int j = 0; j < 4; j++) {   // j only increased if a valid char was found.
+                int c = IA[sArr[s++] & 0xff];
+                if (c >= 0) {
+                    i |= c << (18 - j * 6);
+                } else {
+                    j--;
+                }
+            }
+
+            // Add the bytes
+            dArr[d++] = (byte) (i >> 16);
+            if (d < len) {
+                dArr[d++]= (byte) (i >> 8);
+                if (d < len) {
+                    dArr[d++] = (byte) i;
+                }
+            }
+        }
+
+        return dArr;
+    }
+
+}
diff --git a/src/jexer/bits/package-info.java b/src/jexer/bits/package-info.java
new file mode 100644 (file)
index 0000000..cffe10e
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * 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
+ */
+
+/**
+ * Low-level data objects and utility functions that don't warrant their own
+ * separate package.
+ */
+package jexer.bits;
diff --git a/src/jexer/demos/Demo1.java b/src/jexer/demos/Demo1.java
new file mode 100644 (file)
index 0000000..97088d2
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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.demos;
+
+import jexer.TApplication;
+
+/**
+ * This class is the main driver for a simple demonstration of Jexer's
+ * capabilities.
+ */
+public class Demo1 {
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        try {
+            // Swing is the default backend on Windows unless explicitly
+            // overridden by jexer.Swing.
+            TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+            if (System.getProperty("os.name").startsWith("Windows")) {
+                backendType = TApplication.BackendType.SWING;
+            }
+            if (System.getProperty("os.name").startsWith("Mac")) {
+                backendType = TApplication.BackendType.SWING;
+            }
+            if (System.getProperty("jexer.Swing") != null) {
+                if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                    backendType = TApplication.BackendType.SWING;
+                } else {
+                    backendType = TApplication.BackendType.XTERM;
+                }
+            }
+            DemoApplication app = new DemoApplication(backendType);
+            (new Thread(app)).start();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo2.java b/src/jexer/demos/Demo2.java
new file mode 100644 (file)
index 0000000..2db03ce
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * 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.demos;
+
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.text.MessageFormat;
+import java.util.ResourceBundle;
+
+import jexer.net.TelnetServerSocket;
+
+/**
+ * This class is the main driver for a simple demonstration of Jexer's
+ * capabilities.  Rather than run locally, it serves a Jexer UI over a TCP
+ * port.
+ */
+public class Demo2 {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo2.class.getName());
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        ServerSocket server = null;
+        try {
+            if (args.length == 0) {
+                System.err.println(i18n.getString("usageString"));
+                return;
+            }
+
+            int port = Integer.parseInt(args[0]);
+            server = new TelnetServerSocket(port);
+            while (true) {
+                Socket socket = server.accept();
+                System.out.println(MessageFormat.
+                    format(i18n.getString("newConnection"), socket));
+                DemoApplication app = new DemoApplication(socket.getInputStream(),
+                    socket.getOutputStream());
+                (new Thread(app)).start();
+                Thread.sleep(500);
+                System.out.println(MessageFormat.
+                    format(i18n.getString("terminal"),
+                    ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                        getTerminalType()));
+                System.out.println(MessageFormat.
+                    format(i18n.getString("username"),
+                    ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                        getUsername()));
+                System.out.println(MessageFormat.
+                    format(i18n.getString("language"),
+                    ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                        getLanguage()));
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (server != null) {
+                try {
+                    server.close();
+                } catch (Exception e) {
+                    // SQUASH
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo2.properties b/src/jexer/demos/Demo2.properties
new file mode 100644 (file)
index 0000000..fa2b98f
--- /dev/null
@@ -0,0 +1,5 @@
+usageString=USAGE: java -cp jexer.jar jexer.demos.Demo2 port
+newConnection=New connection: {0}
+username=\ \ \ username: {0}
+language=\ \ \ language: {0}
+terminal=\ \ \ terminal: {0}
diff --git a/src/jexer/demos/Demo3.java b/src/jexer/demos/Demo3.java
new file mode 100644 (file)
index 0000000..f370f8f
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * 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.demos;
+
+import java.io.*;
+
+/**
+ * This class is the main driver for a simple demonstration of Jexer's
+ * capabilities.  This one passes separate Reader/Writer to TApplication,
+ * which will behave quite badly due to System.in/out not being in raw mode.
+ */
+public class Demo3 {
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        try {
+            DemoApplication app = new DemoApplication(System.in,
+                new InputStreamReader(System.in, "UTF-8"),
+                new PrintWriter(new OutputStreamWriter(System.out, "UTF-8")),
+                true);
+            (new Thread(app)).start();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo4.java b/src/jexer/demos/Demo4.java
new file mode 100644 (file)
index 0000000..edbc2c0
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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.demos;
+
+import jexer.*;
+
+/**
+ * This class is the main driver for a simple demonstration of Jexer's
+ * capabilities.  This one shows TDesktop and TWindow API details.
+ */
+public class Demo4 {
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        try {
+            // Swing is the default backend on Windows unless explicitly
+            // overridden by jexer.Swing.
+            TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+            if (System.getProperty("os.name").startsWith("Windows")) {
+                backendType = TApplication.BackendType.SWING;
+            }
+            if (System.getProperty("os.name").startsWith("Mac")) {
+                backendType = TApplication.BackendType.SWING;
+            }
+            if (System.getProperty("jexer.Swing") != null) {
+                if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                    backendType = TApplication.BackendType.SWING;
+                } else {
+                    backendType = TApplication.BackendType.XTERM;
+                }
+            }
+            DesktopDemoApplication app = new DesktopDemoApplication(backendType);
+            (new Thread(app)).start();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo5.java b/src/jexer/demos/Demo5.java
new file mode 100644 (file)
index 0000000..e63abc1
--- /dev/null
@@ -0,0 +1,224 @@
+/*
+ * 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.demos;
+
+import java.awt.Font;
+import java.awt.event.WindowEvent;
+import java.awt.event.WindowListener;
+import java.util.ResourceBundle;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import javax.swing.JSplitPane;
+
+import jexer.backend.SwingBackend;
+
+/**
+ * This class is the main driver for a simple demonstration of Jexer's
+ * capabilities.  It shows two Swing demo applications running in the same
+ * Swing UI.
+ */
+public class Demo5 implements WindowListener {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo5.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The first demo application instance.
+     */
+    DemoApplication app1 = null;
+
+    /**
+     * The second demo application instance.
+     */
+    DemoApplication app2 = null;
+
+    // ------------------------------------------------------------------------
+    // WindowListener ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowActivated(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowClosed(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowClosing(final WindowEvent event) {
+        if (app1 != null) {
+            app1.exit();
+        }
+        if (app2 != null) {
+            app2.exit();
+        }
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowDeactivated(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowDeiconified(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowIconified(final WindowEvent event) {
+        // Ignore
+    }
+
+    /**
+     * Pass window events into the event queue.
+     *
+     * @param event window event received
+     */
+    public void windowOpened(final WindowEvent event) {
+        // Ignore
+    }
+
+    // ------------------------------------------------------------------------
+    // Demo5 ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Run two demo applications in separate panes.
+     */
+    private void addApplications() {
+
+        /*
+         * In this demo we will create two swing panels with two
+         * independently running applications, each with a different font
+         * size.
+         */
+
+        /*
+         * First we create a panel to put it on.  We need this to pass to
+         * SwingBackend's constructor, so that it knows not to create a new
+         * frame.
+         */
+        JPanel app1Panel = new JPanel();
+
+        /*
+         * Next, we create the Swing backend.  The "listener" (second
+         * argument, set to null) is what the backend wakes up on every event
+         * received.  Typically this is the TApplication.  TApplication sets
+         * it in its constructor, so we can pass null here and be fine.
+         */
+        SwingBackend app1Backend = new SwingBackend(app1Panel, null,
+            80, 25, 16);
+        // Now that we have the backend, construct the TApplication.
+        app1 = new DemoApplication(app1Backend);
+
+        /*
+         * The second panel is the same sequence, except that we also change
+         * the font from the default Terminus to JVM monospaced.
+         */
+        JPanel app2Panel = new JPanel();
+        SwingBackend app2Backend = new SwingBackend(app2Panel, null,
+            80, 25, 18);
+        app2 = new DemoApplication(app2Backend);
+        Font font = new Font(Font.MONOSPACED, Font.PLAIN, 18);
+        app2Backend.setFont(font);
+
+        /*
+         * Now that the applications are ready, spin them off on their
+         * threads.
+         */
+        (new Thread(app1)).start();
+        (new Thread(app2)).start();
+
+        /*
+         * The rest of this is standard Swing.  Set up a frame, a split pane,
+         * put each of the panels on it, and make it visible.
+         */
+        JFrame frame = new JFrame();
+        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        frame.addWindowListener(this);
+        JSplitPane mainPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
+            app1Panel, app2Panel);
+        mainPane.setOneTouchExpandable(true);
+        mainPane.setDividerLocation(500);
+        mainPane.setDividerSize(6);
+        mainPane.setBorder(null);
+        frame.setContentPane(mainPane);
+
+        frame.setTitle(i18n.getString("frameTitle"));
+        frame.setSize(1000, 640);
+        frame.setVisible(true);
+    }
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        try {
+            Demo5 demo = new Demo5();
+            demo.addApplications();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo5.properties b/src/jexer/demos/Demo5.properties
new file mode 100644 (file)
index 0000000..56b419d
--- /dev/null
@@ -0,0 +1 @@
+frameTitle=Two Jexer Apps In One Swing UI
diff --git a/src/jexer/demos/Demo6.java b/src/jexer/demos/Demo6.java
new file mode 100644 (file)
index 0000000..41d1f2c
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * 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.demos;
+
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.backend.*;
+import jexer.demos.DemoApplication;
+
+/**
+ * This class shows off the use of MultiBackend and MultiScreen.
+ */
+public class Demo6 {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo6.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Demo6 ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        try {
+
+            /*
+             * In this demo we will create two applications spanning three
+             * screens.  One of the applications will have both an ECMA48
+             * screen and a Swing screen, with all I/O mirrored between them.
+             * The second application will have a Swing screen containing a
+             * window showing the first application, also mirroring I/O
+             * between the window and the other two screens.
+             */
+
+            /*
+             * We create the first screen and use it to establish a
+             * MultiBackend.
+             */
+            ECMA48Backend ecmaBackend = new ECMA48Backend();
+            MultiBackend multiBackend = new MultiBackend(ecmaBackend);
+
+            /*
+             * Now we create the first application (a standard demo).
+             */
+            DemoApplication demoApp = new DemoApplication(multiBackend);
+
+            /*
+             * We will need the width and height of the ECMA48 screen, so get
+             * the Screen reference now.
+             */
+            Screen multiScreen = multiBackend.getScreen();
+
+            /*
+             * Now we create the second screen (backend) for the first
+             * application.  It will be the same size as the ECMA48 screen,
+             * with a font size of 16 points.
+             */
+            SwingBackend swingBackend = new SwingBackend(multiScreen.getWidth(),
+                multiScreen.getHeight(), 16);
+
+            /*
+             * Add this screen to the MultiBackend, and at this point we have
+             * one demo application spanning two physical screens.
+             */
+            multiBackend.addBackend(swingBackend);
+            multiBackend.setListener(demoApp);
+
+            /*
+             * Time for the second application.  This one will have a single
+             * window mirroring the contents of the first application.  Let's
+             * make it a little larger than the first application's
+             * width/height.
+             */
+            int width = multiScreen.getWidth();
+            int height = multiScreen.getHeight();
+
+            /*
+             * Make a new Swing window for the second application.
+             */
+            SwingBackend monitorBackend = new SwingBackend(width + 5,
+                height + 5, 20);
+
+            /*
+             * Setup the second application, give it the basic file and
+             * window menus.
+             */
+            TApplication monitor = new TApplication(monitorBackend);
+            monitor.addToolMenu();
+            monitor.addFileMenu();
+            monitor.addWindowMenu();
+
+            /*
+             * Now add the third screen to the first application.  We want to
+             * change the object it locks on in its draw() method to the
+             * MultiScreen, that will dramatically reduce (not totally
+             * eliminate) screen tearing/artifacts.
+             */
+            TWindowBackend windowBackend = new TWindowBackend(demoApp,
+                monitor, i18n.getString("monitorWindow"),
+                width + 2, height + 2);
+            windowBackend.setDrawLock(multiScreen);
+            windowBackend.setOtherApplication(demoApp);
+            multiBackend.addBackend(windowBackend);
+
+            /*
+             * Three screens, two applications: spin them up!
+             */
+            (new Thread(demoApp)).start();
+            (new Thread(monitor)).start();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo6.properties b/src/jexer/demos/Demo6.properties
new file mode 100644 (file)
index 0000000..450829a
--- /dev/null
@@ -0,0 +1 @@
+monitorWindow=Monitor Window
diff --git a/src/jexer/demos/Demo7.java b/src/jexer/demos/Demo7.java
new file mode 100644 (file)
index 0000000..5f92347
--- /dev/null
@@ -0,0 +1,102 @@
+/*
+ * 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.demos;
+
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.TPanel;
+import jexer.TText;
+import jexer.TWindow;
+import jexer.layout.BoxLayoutManager;
+
+/**
+ * This class shows off BoxLayout and TPanel.
+ */
+public class Demo7 {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo7.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Demo7 ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) throws Exception {
+        // This demo will build everything "from the outside".
+
+        // Swing is the default backend on Windows unless explicitly
+        // overridden by jexer.Swing.
+        TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+        if (System.getProperty("os.name").startsWith("Windows")) {
+            backendType = TApplication.BackendType.SWING;
+        }
+        if (System.getProperty("os.name").startsWith("Mac")) {
+            backendType = TApplication.BackendType.SWING;
+        }
+        if (System.getProperty("jexer.Swing") != null) {
+            if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                backendType = TApplication.BackendType.SWING;
+            } else {
+                backendType = TApplication.BackendType.XTERM;
+            }
+        }
+        TApplication app = new TApplication(backendType);
+        app.addToolMenu();
+        app.addFileMenu();
+        TWindow window = new TWindow(app, i18n.getString("windowTitle"),
+            60, 22);
+        window.setLayoutManager(new BoxLayoutManager(window.getWidth() - 2,
+                window.getHeight() - 2, false));
+
+        TPanel right = window.addPanel(0, 0, 10, 10);
+        TPanel left = window.addPanel(0, 0, 10, 10);
+        right.setLayoutManager(new BoxLayoutManager(right.getWidth(),
+                right.getHeight(), true));
+        left.setLayoutManager(new BoxLayoutManager(left.getWidth(),
+                left.getHeight(), true));
+
+        left.addText("C1", 0, 0, left.getWidth(), left.getHeight());
+        left.addText("C2", 0, 0, left.getWidth(), left.getHeight());
+        left.addText("C3", 0, 0, left.getWidth(), left.getHeight());
+        right.addText("C4", 0, 0, right.getWidth(), right.getHeight());
+        right.addText("C5", 0, 0, right.getWidth(), right.getHeight());
+        right.addText("C6", 0, 0, right.getWidth(), right.getHeight());
+
+        app.run();
+    }
+
+}
diff --git a/src/jexer/demos/Demo7.properties b/src/jexer/demos/Demo7.properties
new file mode 100644 (file)
index 0000000..e6fd7ee
--- /dev/null
@@ -0,0 +1 @@
+windowTitle=BoxLayoutManager Demo
diff --git a/src/jexer/demos/Demo8.java b/src/jexer/demos/Demo8.java
new file mode 100644 (file)
index 0000000..19fe5ff
--- /dev/null
@@ -0,0 +1,154 @@
+/*
+ * 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.demos;
+
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.text.MessageFormat;
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.backend.*;
+import jexer.demos.DemoApplication;
+import jexer.net.TelnetServerSocket;
+
+
+/**
+ * This class shows off the use of MultiBackend and MultiScreen.
+ */
+public class Demo8 {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo8.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Demo8 ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Main entry point.
+     *
+     * @param args Command line arguments
+     */
+    public static void main(final String [] args) {
+        ServerSocket server = null;
+        try {
+
+            /*
+             * In this demo we will create a headless application that anyone
+             * can telnet to.
+             */
+
+            /*
+             * Check the arguments for the port to listen on.
+             */
+            if (args.length == 0) {
+                System.err.println(i18n.getString("usageString"));
+                return;
+            }
+            int port = Integer.parseInt(args[0]);
+
+            /*
+             * We create a headless screen and use it to establish a
+             * MultiBackend.
+             */
+            HeadlessBackend headlessBackend = new HeadlessBackend();
+            MultiBackend multiBackend = new MultiBackend(headlessBackend);
+
+            /*
+             * Now we create the shared application (a standard demo) and
+             * spin it up.
+             */
+            DemoApplication demoApp = new DemoApplication(multiBackend);
+            (new Thread(demoApp)).start();
+            multiBackend.setListener(demoApp);
+
+            /*
+             * Fire up the telnet server.
+             */
+            server = new TelnetServerSocket(port);
+            while (demoApp.isRunning()) {
+                Socket socket = server.accept();
+                System.out.println(MessageFormat.
+                    format(i18n.getString("newConnection"), socket));
+
+                ECMA48Backend ecmaBackend = new ECMA48Backend(demoApp,
+                    socket.getInputStream(),
+                    socket.getOutputStream());
+
+                /*
+                 * Add this screen to the MultiBackend, and at this point we
+                 * have the telnet client able to use the shared demo
+                 * application.
+                 */
+                multiBackend.addBackend(ecmaBackend);
+
+                /*
+                 * Emit the connection information from telnet.
+                 */
+                Thread.sleep(500);
+                System.out.println(MessageFormat.
+                    format(i18n.getString("terminal"),
+                    ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                        getTerminalType()));
+                System.out.println(MessageFormat.
+                    format(i18n.getString("username"),
+                    ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                        getUsername()));
+                System.out.println(MessageFormat.
+                    format(i18n.getString("language"),
+                    ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                        getLanguage()));
+
+            } // while (demoApp.isRunning())
+
+            /*
+             * When the application exits, kill all of the connections too.
+             */
+            multiBackend.shutdown();
+            server.close();
+
+            System.out.println(i18n.getString("exitMain"));
+
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (server != null) {
+                try {
+                    server.close();
+                } catch (Exception e) {
+                    // SQUASH
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo8.properties b/src/jexer/demos/Demo8.properties
new file mode 100644 (file)
index 0000000..08a8217
--- /dev/null
@@ -0,0 +1,6 @@
+usageString=USAGE: java -cp jexer.jar jexer.demos.Demo8 port
+newConnection=New connection: {0}
+username=\ \ \ username: {0}
+language=\ \ \ language: {0}
+terminal=\ \ \ terminal: {0}
+exitMain=Main thread is exiting...
diff --git a/src/jexer/demos/DemoApplication.java b/src/jexer/demos/DemoApplication.java
new file mode 100644 (file)
index 0000000..3e4cbe9
--- /dev/null
@@ -0,0 +1,247 @@
+/*
+ * 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.demos;
+
+import java.io.File;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.TEditColorThemeWindow;
+import jexer.TEditorWindow;
+import jexer.event.TMenuEvent;
+import jexer.menu.TMenu;
+import jexer.menu.TMenuItem;
+import jexer.menu.TSubMenu;
+import jexer.backend.Backend;
+import jexer.backend.SwingTerminal;
+
+/**
+ * The demo application itself.
+ */
+public class DemoApplication extends TApplication {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoApplication.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; shutdown() will (blindly!) put System.in in cooked
+     * mode.  input is always converted to a Reader with UTF-8 encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public DemoApplication(final InputStream input,
+        final OutputStream output) throws UnsupportedEncodingException {
+        super(input, output);
+        addAllWidgets();
+
+        getBackend().setTitle(i18n.getString("applicationTitle"));
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @param setRawMode if true, set System.in into raw mode with stty.
+     * This should in general not be used.  It is here solely for Demo3,
+     * which uses System.in.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public DemoApplication(final InputStream input, final Reader reader,
+        final PrintWriter writer, final boolean setRawMode) {
+        super(input, reader, writer, setRawMode);
+        addAllWidgets();
+
+        getBackend().setTitle(i18n.getString("applicationTitle"));
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param input the InputStream underlying 'reader'.  Its available()
+     * method is used to determine if reader.read() will block or not.
+     * @param reader a Reader connected to the remote user.
+     * @param writer a PrintWriter connected to the remote user.
+     * @throws IllegalArgumentException if input, reader, or writer are null.
+     */
+    public DemoApplication(final InputStream input, final Reader reader,
+        final PrintWriter writer) {
+
+        this(input, reader, writer, false);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param backend a Backend that is already ready to go.
+     */
+    public DemoApplication(final Backend backend) {
+        super(backend);
+
+        addAllWidgets();
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param backendType one of the TApplication.BackendType values
+     * @throws Exception if TApplication can't instantiate the Backend.
+     */
+    public DemoApplication(final BackendType backendType) throws Exception {
+        // For the Swing demo, use an initial size of 82x28 so that a
+        // terminal window precisely fits the window.
+        super(backendType, (backendType == BackendType.SWING ? 82 : -1),
+            (backendType == BackendType.SWING ? 28 : -1), 20);
+        addAllWidgets();
+        getBackend().setTitle(i18n.getString("applicationTitle"));
+    }
+
+    // ------------------------------------------------------------------------
+    // TApplication -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle menu events.
+     *
+     * @param menu menu event
+     * @return if true, the event was processed and should not be passed onto
+     * a window
+     */
+    @Override
+    public boolean onMenu(final TMenuEvent menu) {
+
+        if (menu.getId() == 3000) {
+            // Bigger +2
+            assert (getScreen() instanceof SwingTerminal);
+            SwingTerminal terminal = (SwingTerminal) getScreen();
+            terminal.setFontSize(terminal.getFontSize() + 2);
+            return true;
+        }
+        if (menu.getId() == 3001) {
+            // Smaller -2
+            assert (getScreen() instanceof SwingTerminal);
+            SwingTerminal terminal = (SwingTerminal) getScreen();
+            terminal.setFontSize(terminal.getFontSize() - 2);
+            return true;
+        }
+
+        if (menu.getId() == 2050) {
+            new TEditColorThemeWindow(this);
+            return true;
+        }
+
+        if (menu.getId() == TMenu.MID_OPEN_FILE) {
+            try {
+                String filename = fileOpenBox(".");
+                 if (filename != null) {
+                     try {
+                         new TEditorWindow(this, new File(filename));
+                     } catch (IOException e) {
+                         e.printStackTrace();
+                     }
+                 }
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+            return true;
+        }
+        return super.onMenu(menu);
+    }
+
+    // ------------------------------------------------------------------------
+    // DemoApplication --------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Add all the widgets of the demo.
+     */
+    private void addAllWidgets() {
+        new DemoMainWindow(this);
+
+        // Add the menus
+        addToolMenu();
+        addFileMenu();
+        addEditMenu();
+
+        TMenu demoMenu = addMenu(i18n.getString("demo"));
+        TMenuItem item = demoMenu.addItem(2000, i18n.getString("checkable"));
+        item.setCheckable(true);
+        item = demoMenu.addItem(2001, i18n.getString("disabled"));
+        item.setEnabled(false);
+        item = demoMenu.addItem(2002, i18n.getString("normal"));
+        TSubMenu subMenu = demoMenu.addSubMenu(i18n.getString("subMenu"));
+        item = demoMenu.addItem(2010, i18n.getString("normal"));
+        item = demoMenu.addItem(2050, i18n.getString("colors"));
+
+        item = subMenu.addItem(2000, i18n.getString("checkableSub"));
+        item.setCheckable(true);
+        item = subMenu.addItem(2001, i18n.getString("disabledSub"));
+        item.setEnabled(false);
+        item = subMenu.addItem(2002, i18n.getString("normalSub"));
+
+        subMenu = subMenu.addSubMenu(i18n.getString("subMenu"));
+        item = subMenu.addItem(2000, i18n.getString("checkableSub"));
+        item.setCheckable(true);
+        item = subMenu.addItem(2001, i18n.getString("disabledSub"));
+        item.setEnabled(false);
+        item = subMenu.addItem(2002, i18n.getString("normalSub"));
+
+        if (getScreen() instanceof SwingTerminal) {
+            TMenu swingMenu = addMenu(i18n.getString("swing"));
+            item = swingMenu.addItem(3000, i18n.getString("bigger"));
+            item = swingMenu.addItem(3001, i18n.getString("smaller"));
+        }
+
+        addTableMenu();
+        addWindowMenu();
+        addHelpMenu();
+    }
+
+}
diff --git a/src/jexer/demos/DemoApplication.properties b/src/jexer/demos/DemoApplication.properties
new file mode 100644 (file)
index 0000000..95d8603
--- /dev/null
@@ -0,0 +1,15 @@
+applicationTitle=Demo Application
+
+demo=&Demo
+checkable=&Checkable
+disabled=Disabled
+normal=&Normal
+subMenu=Sub-&Menu
+normal=N&ormal A&&D
+colors=Co&lors...
+checkableSub=&Checkable (sub)
+disabledSub=Disabled (sub)
+normalSub=&Normal (sub)
+swing=Swin&g
+bigger=&Bigger +2
+smaller=&Smaller -2
diff --git a/src/jexer/demos/DemoCheckBoxWindow.java b/src/jexer/demos/DemoCheckBoxWindow.java
new file mode 100644 (file)
index 0000000..faf3530
--- /dev/null
@@ -0,0 +1,155 @@
+/*
+ * 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.demos;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ResourceBundle;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TComboBox;
+import jexer.TMessageBox;
+import jexer.TRadioGroup;
+import jexer.TWindow;
+import jexer.layout.StretchLayoutManager;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TRadioGroup, TRadioButton, and TCheckBox
+ * widgets.
+ */
+public class DemoCheckBoxWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoCheckBoxWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Combo box.  Has to be at class scope so that it can be accessed by the
+     * anonymous TAction class.
+     */
+    TComboBox comboBox = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     */
+    DemoCheckBoxWindow(final TApplication parent) {
+        this(parent, CENTERED | RESIZABLE);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+     */
+    DemoCheckBoxWindow(final TApplication parent, final int flags) {
+        // Construct a demo window.  X and Y don't matter because it will be
+        // centered on screen.
+        super(parent, i18n.getString("windowTitle"), 0, 0, 60, 17, flags);
+
+        setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                getHeight() - 2));
+
+        int row = 1;
+
+        // Add some widgets
+        addLabel(i18n.getString("checkBoxLabel1"), 1, row);
+        addCheckBox(35, row++, i18n.getString("checkBoxText1"), false);
+        addLabel(i18n.getString("checkBoxLabel2"), 1, row);
+        addCheckBox(35, row++, i18n.getString("checkBoxText2"), true);
+        row += 2;
+
+        TRadioGroup group = addRadioGroup(1, row,
+            i18n.getString("radioGroupTitle"));
+        group.addRadioButton(i18n.getString("radioOption1"));
+        group.addRadioButton(i18n.getString("radioOption2"), true);
+        group.addRadioButton(i18n.getString("radioOption3"));
+        group.setRequiresSelection(true);
+
+        List<String> comboValues = new ArrayList<String>();
+        comboValues.add(i18n.getString("comboBoxString0"));
+        comboValues.add(i18n.getString("comboBoxString1"));
+        comboValues.add(i18n.getString("comboBoxString2"));
+        comboValues.add(i18n.getString("comboBoxString3"));
+        comboValues.add(i18n.getString("comboBoxString4"));
+        comboValues.add(i18n.getString("comboBoxString5"));
+        comboValues.add(i18n.getString("comboBoxString6"));
+        comboValues.add(i18n.getString("comboBoxString7"));
+        comboValues.add(i18n.getString("comboBoxString8"));
+        comboValues.add(i18n.getString("comboBoxString9"));
+        comboValues.add(i18n.getString("comboBoxString10"));
+
+        comboBox = addComboBox(35, row, 12, comboValues, 2, 6,
+            new TAction() {
+                public void DO() {
+                    getApplication().messageBox(i18n.getString("messageBoxTitle"),
+                        MessageFormat.format(i18n.getString("messageBoxPrompt"),
+                            comboBox.getText()),
+                        TMessageBox.Type.OK);
+                }
+            }
+        );
+
+        addButton(i18n.getString("closeWindow"),
+            (getWidth() - 14) / 2, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    DemoCheckBoxWindow.this.getApplication()
+                        .closeWindow(DemoCheckBoxWindow.this);
+                }
+            }
+        );
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+}
diff --git a/src/jexer/demos/DemoCheckBoxWindow.properties b/src/jexer/demos/DemoCheckBoxWindow.properties
new file mode 100644 (file)
index 0000000..61210ce
--- /dev/null
@@ -0,0 +1,30 @@
+windowTitle=Radiobuttons, CheckBoxes, and ComboBox
+
+statusBar=Radiobuttons and checkboxes
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
+
+checkBoxLabel1=Check box example 1
+checkBoxText1=CheckBox 1
+checkBoxLabel2=Check box example 2
+checkBoxText2=CheckBox 2
+radioGroupTitle=Group 1
+radioOption1=Radio option 1
+radioOption2=Radio option 2
+radioOption3=Radio option 3
+comboBoxString0=String 0
+comboBoxString1=String 1
+comboBoxString2=String 2
+comboBoxString3=String 3
+comboBoxString4=String 4
+comboBoxString5=String 5
+comboBoxString6=String 6
+comboBoxString7=String 7
+comboBoxString8=String 8
+comboBoxString9=String 9
+comboBoxString10=String 10
+messageBoxTitle=ComboBox
+messageBoxPrompt=You selected the following value:\n\n{0}\n
+closeWindow=&Close Window
diff --git a/src/jexer/demos/DemoEditorWindow.java b/src/jexer/demos/DemoEditorWindow.java
new file mode 100644 (file)
index 0000000..87798fb
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * 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.demos;
+
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.TEditorWidget;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TEditor widget.
+ */
+public class DemoEditorWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoEditorWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto my TEditor so I can resize it with the window.
+     */
+    private TEditorWidget editField;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor makes a text window out of any string.
+     *
+     * @param parent the main application
+     * @param title the text string
+     * @param text the text string
+     */
+    public DemoEditorWindow(final TApplication parent, final String title,
+        final String text) {
+
+        super(parent, title, 0, 0, 44, 22, RESIZABLE);
+        editField = addEditor(text, 0, 0, 42, 20);
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     */
+    public DemoEditorWindow(final TApplication parent) {
+        this(parent, i18n.getString("windowTitle"),
+"This is an example of an editable text field.  Some example text follows.\n" +
+"\n" +
+"This library implements a text-based windowing system loosely\n" +
+"reminiscent of Borland's [Turbo\n" +
+"Vision](http://en.wikipedia.org/wiki/Turbo_Vision) library.  For those\n" +
+"wishing to use the actual C++ Turbo Vision library, see [Sergio\n" +
+"Sigala's updated version](http://tvision.sourceforge.net/) that runs\n" +
+"on many more platforms.\n" +
+"\n" +
+"This library is licensed MIT.  See the file LICENSE for the full license\n" +
+"for the details.\n" +
+"\n" +
+"package jexer.demos;\n" +
+"\n" +
+"import jexer.*;\n" +
+"import jexer.event.*;\n" +
+"import static jexer.TCommand.*;\n" +
+"import static jexer.TKeypress.*;\n" +
+"\n" +
+"/**\n" +
+" * This window demonstates the TText, THScroller, and TVScroller widgets.\n" +
+" */\n" +
+"public class DemoEditorWindow extends TWindow {\n" +
+"\n" +
+"1 2 3 123\n" +
+"\n"
+        );
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the text field
+            TResizeEvent editSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                event.getWidth() - 2, event.getHeight() - 2);
+            editField.onResize(editSize);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(event);
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DemoEditorWindow.properties b/src/jexer/demos/DemoEditorWindow.properties
new file mode 100644 (file)
index 0000000..3fa3212
--- /dev/null
@@ -0,0 +1,6 @@
+windowTitle=Editor
+
+statusBar=Editable text demo window
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarExit=Exit
diff --git a/src/jexer/demos/DemoMainWindow.java b/src/jexer/demos/DemoMainWindow.java
new file mode 100644 (file)
index 0000000..8f77448
--- /dev/null
@@ -0,0 +1,370 @@
+/*
+ * 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.demos;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ResourceBundle;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TEditColorThemeWindow;
+import jexer.TEditorWindow;
+import jexer.TLabel;
+import jexer.TProgressBar;
+import jexer.TTableWindow;
+import jexer.TTimer;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TCommandEvent;
+import jexer.layout.StretchLayoutManager;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This is the main "demo" application window.  It makes use of the TTimer,
+ * TProgressBox, TLabel, TButton, and TField widgets.
+ */
+public class DemoMainWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoMainWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Timer that increments a number.
+     */
+    private TTimer timer1;
+
+    /**
+     * Timer that increments a number.
+     */
+    private TTimer timer2;
+
+    /**
+     * Timer label is updated with timer ticks.
+     */
+    TLabel timerLabel;
+
+    /**
+     * Timer increment used by the timer loop.  Has to be at class scope so
+     * that it can be accessed by the anonymous TAction class.
+     */
+    int timer1I = 0;
+
+    /**
+     * Timer increment used by the timer loop.  Has to be at class scope so
+     * that it can be accessed by the anonymous TAction class.
+     */
+    int timer2I = 0;
+
+    /**
+     * Progress bar used by the timer loop.  Has to be at class scope so that
+     * it can be accessed by the anonymous TAction class.
+     */
+    TProgressBar progressBar1;
+
+    /**
+     * Progress bar used by the timer loop.  Has to be at class scope so that
+     * it can be accessed by the anonymous TAction class.
+     */
+    TProgressBar progressBar2;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Construct demo window.  It will be centered on screen.
+     *
+     * @param parent the main application
+     */
+    public DemoMainWindow(final TApplication parent) {
+        this(parent, CENTERED | RESIZABLE);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+     */
+    private DemoMainWindow(final TApplication parent, final int flags) {
+        // Construct a demo window.  X and Y don't matter because it will be
+        // centered on screen.
+        super(parent, i18n.getString("windowTitle"), 0, 0, 64, 23, flags);
+
+        setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                getHeight() - 2));
+
+        int row = 1;
+
+        // Add some widgets
+        addLabel(i18n.getString("messageBoxLabel"), 1, row);
+        TWidget first = addButton(i18n.getString("messageBoxButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoMsgBoxWindow(getApplication());
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("openModalLabel"), 1, row);
+        addButton(i18n.getString("openModalButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoMainWindow(getApplication(), MODAL);
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("textFieldLabel"), 1, row);
+        addButton(i18n.getString("textFieldButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoTextFieldWindow(getApplication());
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("radioButtonLabel"), 1, row);
+        addButton(i18n.getString("radioButtonButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoCheckBoxWindow(getApplication());
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("editorLabel"), 1, row);
+        addButton(i18n.getString("editorButton1"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoEditorWindow(getApplication());
+                }
+            }
+        );
+        addButton(i18n.getString("editorButton2"), 48, row,
+            new TAction() {
+                public void DO() {
+                    new TEditorWindow(getApplication());
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("textAreaLabel"), 1, row);
+        addButton(i18n.getString("textAreaButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoTextWindow(getApplication());
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("ttableLabel"), 1, row);
+        addButton(i18n.getString("ttableButton1"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new DemoTableWindow(getApplication(),
+                        i18n.getString("tableWidgetDemo"));
+                }
+            }
+        );
+        addButton(i18n.getString("ttableButton2"), 48, row,
+            new TAction() {
+                public void DO() {
+                    new TTableWindow(getApplication(),
+                        i18n.getString("tableDemo"));
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("treeViewLabel"), 1, row);
+        addButton(i18n.getString("treeViewButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    try {
+                        new DemoTreeViewWindow(getApplication());
+                    } catch (Exception e) {
+                        e.printStackTrace();
+                    }
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("terminalLabel"), 1, row);
+        addButton(i18n.getString("terminalButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    getApplication().openTerminal(0, 0);
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("colorEditorLabel"), 1, row);
+        addButton(i18n.getString("colorEditorButton"), 35, row,
+            new TAction() {
+                public void DO() {
+                    new TEditColorThemeWindow(getApplication());
+                }
+            }
+        );
+
+        row = 15;
+        progressBar1 = addProgressBar(48, row, 12, 0);
+        row++;
+        timerLabel = addLabel(i18n.getString("timerLabel"), 48, row);
+        timer1 = getApplication().addTimer(250, true,
+            new TAction() {
+
+                public void DO() {
+                    timerLabel.setLabel(String.format(i18n.
+                            getString("timerText"), timer1I));
+                    timerLabel.setWidth(timerLabel.getLabel().length());
+                    if (timer1I < 100) {
+                        timer1I++;
+                    } else {
+                        timer1.setRecurring(false);
+                    }
+                    progressBar1.setValue(timer1I);
+                }
+            }
+        );
+
+        row += 2;
+        progressBar2 = addProgressBar(48, row, 12, 0);
+        progressBar2.setLeftBorderChar('\u255e');
+        progressBar2.setRightBorderChar('\u2561');
+        progressBar2.setCompletedChar('\u2592');
+        progressBar2.setRemainingChar('\u2550');
+        row++;
+        timer2 = getApplication().addTimer(125, true,
+            new TAction() {
+
+                public void DO() {
+                    if (timer2I < 100) {
+                        timer2I++;
+                    } else {
+                        timer2.setRecurring(false);
+                    }
+                    progressBar2.setValue(timer2I);
+                }
+            }
+        );
+
+        /*
+        addButton("Exception", 35, row + 3,
+            new TAction() {
+                public void DO() {
+                    try {
+                        throw new RuntimeException("FUBAR'd!");
+                    } catch (Exception e) {
+                        new jexer.TExceptionDialog(getApplication(), e);
+                    }
+                }
+            }
+        );
+         */
+
+        activate(first);
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * We need to override onClose so that the timer will no longer be called
+     * after we close the window.  TTimers currently are completely unaware
+     * of the rest of the UI classes.
+     */
+    @Override
+    public void onClose() {
+        getApplication().removeTimer(timer1);
+        getApplication().removeTimer(timer2);
+    }
+
+    /**
+     * Method that subclasses can override to handle posted command events.
+     *
+     * @param command command event
+     */
+    @Override
+    public void onCommand(final TCommandEvent command) {
+        if (command.equals(cmOpen)) {
+            try {
+                String filename = fileOpenBox(".");
+                if (filename != null) {
+                    try {
+                        new TEditorWindow(getApplication(),
+                            new File(filename));
+                    } catch (IOException e) {
+                        messageBox(i18n.getString("errorTitle"),
+                            MessageFormat.format(i18n.
+                                getString("errorReadingFile"), e.getMessage()));
+                    }
+                }
+            } catch (IOException e) {
+                        messageBox(i18n.getString("errorTitle"),
+                            MessageFormat.format(i18n.
+                                getString("errorOpeningFile"), e.getMessage()));
+            }
+            return;
+        }
+
+        // Didn't handle it, let children get it instead
+        super.onCommand(command);
+    }
+
+}
diff --git a/src/jexer/demos/DemoMainWindow.properties b/src/jexer/demos/DemoMainWindow.properties
new file mode 100644 (file)
index 0000000..dba1cb0
--- /dev/null
@@ -0,0 +1,39 @@
+windowTitle=Demo Window
+
+statusBar=Demo Main Window
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
+
+messageBoxLabel=Message Boxes
+messageBoxButton=&MessageBoxes
+openModalLabel=Open me as modal
+openModalButton=M&odal
+textFieldLabel=Text fields, calendar, spinner
+textFieldButton=Field&s
+radioButtonLabel=Radio buttons, checkbox, combobox
+radioButtonButton=&CheckBoxes
+editorLabel=Editor window
+editorButton1=&1 Widget
+editorButton2=&2 Window
+ttableLabel=Editable Table
+ttableButton1=&4 Widget
+ttableButton2=&5 Window
+textAreaLabel=Text areas
+textAreaButton=&3 Text
+treeViewLabel=Tree views
+treeViewButton=Tree&View
+terminalLabel=Terminal
+terminalButton=Termi&nal
+colorEditorLabel=Color editor
+colorEditorButton=Co&lors
+timerLabel=Timer
+timerText=Timer: %d
+
+errorTitle=Error
+errorReadingFile=Error reading file: {0}
+errorOpeningFile=Error opening file dialog: {0}
+
+tableWidgetDemo=TTableWidget Demo
+tableDemo=TTableWindow Demo
diff --git a/src/jexer/demos/DemoMsgBoxWindow.java b/src/jexer/demos/DemoMsgBoxWindow.java
new file mode 100644 (file)
index 0000000..0485f51
--- /dev/null
@@ -0,0 +1,190 @@
+/*
+ * 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.demos;
+
+import java.text.MessageFormat;
+import java.util.ResourceBundle;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TInputBox;
+import jexer.TMessageBox;
+import jexer.TWindow;
+import jexer.layout.StretchLayoutManager;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TMessageBox and TInputBox widgets.
+ */
+public class DemoMsgBoxWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoMsgBoxWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     */
+    DemoMsgBoxWindow(final TApplication parent) {
+        this(parent, TWindow.CENTERED | TWindow.RESIZABLE);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+     */
+    DemoMsgBoxWindow(final TApplication parent, final int flags) {
+        // Construct a demo window.  X and Y don't matter because it
+        // will be centered on screen.
+        super(parent, i18n.getString("windowTitle"), 0, 0, 64, 18, flags);
+
+        setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                getHeight() - 2));
+
+        int row = 1;
+
+        // Add some widgets
+        addLabel(i18n.getString("messageBoxLabel1"), 1, row);
+        addButton(i18n.getString("messageBoxButton1"), 35, row,
+            new TAction() {
+                public void DO() {
+                    getApplication().messageBox(i18n.
+                        getString("messageBoxTitle1"),
+                        i18n.getString("messageBoxPrompt1"),
+                        TMessageBox.Type.OK);
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("messageBoxLabel2"), 1, row);
+        addButton(i18n.getString("messageBoxButton2"), 35, row,
+            new TAction() {
+                public void DO() {
+                    getApplication().messageBox(i18n.
+                        getString("messageBoxTitle2"),
+                        i18n.getString("messageBoxPrompt2"),
+                        TMessageBox.Type.OKCANCEL);
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("messageBoxLabel3"), 1, row);
+        addButton(i18n.getString("messageBoxButton3"), 35, row,
+            new TAction() {
+                public void DO() {
+                    getApplication().messageBox(i18n.
+                        getString("messageBoxTitle3"),
+                        i18n.getString("messageBoxPrompt3"),
+                        TMessageBox.Type.YESNO);
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("messageBoxLabel4"), 1, row);
+        addButton(i18n.getString("messageBoxButton4"), 35, row,
+            new TAction() {
+                public void DO() {
+                    getApplication().messageBox(i18n.
+                        getString("messageBoxTitle4"),
+                        i18n.getString("messageBoxPrompt4"),
+                        TMessageBox.Type.YESNOCANCEL);
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("inputBoxLabel1"), 1, row);
+        addButton(i18n.getString("inputBoxButton1"), 35, row,
+            new TAction() {
+                public void DO() {
+                    TInputBox in = getApplication().inputBox(i18n.
+                        getString("inputBoxTitle1"),
+                        i18n.getString("inputBoxPrompt1"),
+                        i18n.getString("inputBoxInput1"));
+                    getApplication().messageBox(i18n.
+                        getString("inputBoxAnswerTitle1"),
+                        MessageFormat.format(i18n.
+                            getString("inputBoxAnswerPrompt1"), in.getText()));
+                }
+            }
+        );
+        row += 2;
+
+        addLabel(i18n.getString("inputBoxLabel2"), 1, row);
+        addButton(i18n.getString("inputBoxButton2"), 35, row,
+            new TAction() {
+                public void DO() {
+                    TInputBox in = getApplication().inputBox(i18n.
+                        getString("inputBoxTitle2"),
+                        i18n.getString("inputBoxPrompt2"),
+                        i18n.getString("inputBoxInput2"),
+                        TInputBox.Type.OKCANCEL);
+                    getApplication().messageBox(i18n.
+                        getString("inputBoxAnswerTitle2"),
+                        MessageFormat.format(i18n.
+                            getString("inputBoxAnswerPrompt2"), in.getText(),
+                            in.getResult()));
+                }
+            }
+        );
+        row += 2;
+
+        addButton(i18n.getString("closeWindow"),
+            (getWidth() - 14) / 2, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    getApplication().closeWindow(DemoMsgBoxWindow.this);
+                }
+            }
+        );
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+}
diff --git a/src/jexer/demos/DemoMsgBoxWindow.properties b/src/jexer/demos/DemoMsgBoxWindow.properties
new file mode 100644 (file)
index 0000000..47a858a
--- /dev/null
@@ -0,0 +1,45 @@
+windowTitle=Message Boxes
+
+statusBar=Message boxes
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
+
+messageBoxLabel1=Default OK message box
+messageBoxButton1=Open O&K MB
+messageBoxTitle1=OK MessageBox
+messageBoxPrompt1=This is an example of a OK MessageBox.  This is the\ndefault MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-left\nclose button) is OK.\n
+
+messageBoxLabel2=OK/Cancel message box
+messageBoxButton2=O&pen OKC MB
+messageBoxTitle2=OK/Cancel MessageBox
+messageBoxPrompt2=This is an example of a OK/Cancel MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-leftclose button) is CANCEL.\n
+
+messageBoxLabel3=Yes/No message box
+messageBoxButton3=Open &YN MB
+messageBoxTitle3=Yes/No MessageBox
+messageBoxPrompt3=This is an example of a Yes/No MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-left\nclose button) is NO.\n
+
+messageBoxLabel4=Yes/No/Cancel message box
+messageBoxButton4=Ope&n YNC MB
+messageBoxTitle4=Yes/No/Cancel MessageBox
+messageBoxPrompt4=This is an example of a Yes/No/Cancel MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-left\nclose button) is CANCEL.\n
+
+inputBoxLabel1=Input box 1
+inputBoxButton1=Open &input box
+inputBoxTitle1=Input Box
+inputBoxPrompt1=This is an example of an InputBox.\n\nNote that the InputBox text can span multiple\nlines.\n
+inputBoxInput1=some input text
+inputBoxAnswerTitle1=Your InputBox Answer
+inputBoxAnswerPrompt1=You entered: {0}
+
+inputBoxLabel2=Input box 2
+inputBoxButton2=Cance&llable input box
+inputBoxTitle2=Input Box
+inputBoxPrompt2=This is an example of an InputBox.\n\nNote that the InputBox text can span multiple\nlines.\nThis one has both OK and Cancel buttons.\n
+inputBoxInput2=some input text
+inputBoxAnswerTitle2=Your InputBox Answer
+inputBoxAnswerPrompt2=You entered: {0} and pressed {1}
+
+closeWindow=&Close Window
diff --git a/src/jexer/demos/DemoTableWindow.java b/src/jexer/demos/DemoTableWindow.java
new file mode 100644 (file)
index 0000000..85da32a
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * 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.demos;
+
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.TTableWidget;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TTable widget.
+ */
+public class DemoTableWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTableWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto my TTable so I can resize it with the window.
+     */
+    private TTableWidget tableField;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor makes a text window out of any string.
+     *
+     * @param parent the main application
+     * @param title the text string
+     */
+    public DemoTableWindow(final TApplication parent, final String title) {
+
+        super(parent, title, 0, 0, 44, 22, RESIZABLE);
+        tableField = new TTableWidget(this, 0, 0, 42, 20);
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     */
+    public DemoTableWindow(final TApplication parent) {
+        this(parent, i18n.getString("windowTitle"));
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the text field
+            TResizeEvent tableSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                event.getWidth() - 2, event.getHeight() - 2);
+            tableField.onResize(tableSize);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(event);
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DemoTableWindow.properties b/src/jexer/demos/DemoTableWindow.properties
new file mode 100644 (file)
index 0000000..ecc9ec5
--- /dev/null
@@ -0,0 +1,6 @@
+windowTitle=Table
+
+statusBar=Table datagrid demo window
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarExit=Exit
diff --git a/src/jexer/demos/DemoTextFieldWindow.java b/src/jexer/demos/DemoTextFieldWindow.java
new file mode 100644 (file)
index 0000000..2c6116a
--- /dev/null
@@ -0,0 +1,182 @@
+/*
+ * 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.demos;
+
+import java.text.MessageFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Locale;
+import java.util.ResourceBundle;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TCalendar;
+import jexer.TField;
+import jexer.TLabel;
+import jexer.TMessageBox;
+import jexer.TWindow;
+import jexer.layout.StretchLayoutManager;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TField and TPasswordField widgets.
+ */
+public class DemoTextFieldWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTextFieldWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Calendar.  Has to be at class scope so that it can be accessed by the
+     * anonymous TAction class.
+     */
+    TCalendar calendar = null;
+
+    /**
+     * Day of week label is updated with TSpinner clicks.
+     */
+    TLabel dayOfWeekLabel;
+
+    /**
+     * Day of week to demonstrate TSpinner.  Has to be at class scope so that
+     * it can be accessed by the anonymous TAction class.
+     */
+    GregorianCalendar dayOfWeekCalendar = new GregorianCalendar();
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     */
+    DemoTextFieldWindow(final TApplication parent) {
+        this(parent, TWindow.CENTERED | TWindow.RESIZABLE);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param parent the main application
+     * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+     */
+    DemoTextFieldWindow(final TApplication parent, final int flags) {
+        // Construct a demo window.  X and Y don't matter because it
+        // will be centered on screen.
+        super(parent, i18n.getString("windowTitle"), 0, 0, 60, 20, flags);
+
+        setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                getHeight() - 2));
+
+        int row = 1;
+
+        addLabel(i18n.getString("textField1"), 1, row);
+        addField(35, row++, 15, false, "Field text");
+        addLabel(i18n.getString("textField2"), 1, row);
+        addField(35, row++, 15, true);
+        addLabel(i18n.getString("textField3"), 1, row);
+        addPasswordField(35, row++, 15, false);
+        addLabel(i18n.getString("textField4"), 1, row);
+        addPasswordField(35, row++, 15, true, "hunter2");
+        addLabel(i18n.getString("textField5"), 1, row);
+        TField selected = addField(35, row++, 40, false,
+            i18n.getString("textField6"));
+        row += 1;
+
+        calendar = addCalendar(1, row++,
+            new TAction() {
+                public void DO() {
+                    getApplication().messageBox(i18n.getString("calendarTitle"),
+                        MessageFormat.format(i18n.getString("calendarMessage"),
+                            new Date(calendar.getValue().getTimeInMillis())),
+                        TMessageBox.Type.OK);
+                }
+            }
+        );
+
+        dayOfWeekLabel = addLabel("Wednesday-", 35, row - 1, "tmenu", false);
+        dayOfWeekLabel.setLabel(String.format("%-10s",
+                dayOfWeekCalendar.getDisplayName(Calendar.DAY_OF_WEEK,
+                    Calendar.LONG, Locale.getDefault())));
+
+        addSpinner(35 + dayOfWeekLabel.getWidth(), row - 1,
+            new TAction() {
+                public void DO() {
+                    dayOfWeekCalendar.add(Calendar.DAY_OF_WEEK, 1);
+                    dayOfWeekLabel.setLabel(String.format("%-10s",
+                            dayOfWeekCalendar.getDisplayName(
+                            Calendar.DAY_OF_WEEK, Calendar.LONG,
+                            Locale.getDefault())));
+                }
+            },
+            new TAction() {
+                public void DO() {
+                    dayOfWeekCalendar.add(Calendar.DAY_OF_WEEK, -1);
+                    dayOfWeekLabel.setLabel(String.format("%-10s",
+                            dayOfWeekCalendar.getDisplayName(
+                            Calendar.DAY_OF_WEEK, Calendar.LONG,
+                            Locale.getDefault())));
+                }
+            }
+        );
+
+
+        addButton(i18n.getString("closeWindow"),
+            (getWidth() - 14) / 2, getHeight() - 4,
+            new TAction() {
+                public void DO() {
+                    getApplication().closeWindow(DemoTextFieldWindow.this);
+                }
+            }
+        );
+
+        activate(selected);
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+}
diff --git a/src/jexer/demos/DemoTextFieldWindow.properties b/src/jexer/demos/DemoTextFieldWindow.properties
new file mode 100644 (file)
index 0000000..5b42990
--- /dev/null
@@ -0,0 +1,17 @@
+windowTitle=Text Fields
+
+statusBar=Text fields
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
+
+textField1=Variable-width text field:
+textField2=Fixed-width text field:
+textField3=Variable-width password:
+textField4=Fixed-width password:
+textField5=Very long text field:
+textField6=Very very long field text that should be outside the window
+calendarTitle=Calendar
+calendarMessage=You selected the following date:\n\n{0}\n
+closeWindow=&Close Window
diff --git a/src/jexer/demos/DemoTextWindow.java b/src/jexer/demos/DemoTextWindow.java
new file mode 100644 (file)
index 0000000..7490886
--- /dev/null
@@ -0,0 +1,181 @@
+/*
+ * 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.demos;
+
+import java.util.ResourceBundle;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TText;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TResizeEvent;
+import jexer.menu.TMenu;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TText, THScroller, and TVScroller widgets.
+ */
+public class DemoTextWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTextWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto my TText so I can resize it with the window.
+     */
+    private TText textField;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor makes a text window out of any string.
+     *
+     * @param parent the main application
+     * @param title the text string
+     * @param text the text string
+     */
+    public DemoTextWindow(final TApplication parent, final String title,
+        final String text) {
+
+        super(parent, title, 0, 0, 44, 22, RESIZABLE);
+        textField = addText(text, 1, 3, 40, 16);
+
+        addButton(i18n.getString("left"), 1, 1, new TAction() {
+                public void DO() {
+                    textField.leftJustify();
+                }
+        });
+
+        addButton(i18n.getString("center"), 10, 1, new TAction() {
+                public void DO() {
+                    textField.centerJustify();
+                }
+        });
+
+        addButton(i18n.getString("right"), 21, 1, new TAction() {
+                public void DO() {
+                    textField.rightJustify();
+                }
+        });
+
+        addButton(i18n.getString("full"), 31, 1, new TAction() {
+                public void DO() {
+                    textField.fullJustify();
+                }
+        });
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     */
+    public DemoTextWindow(final TApplication parent) {
+        this(parent, i18n.getString("windowTitle"),
+"This is an example of a reflowable text field.  Some example text follows.\n" +
+"\n" +
+"Notice that some menu items should be disabled when this window has focus.\n" +
+"\n" +
+"This library implements a text-based windowing system loosely " +
+"reminiscent of Borland's [Turbo " +
+"Vision](http://en.wikipedia.org/wiki/Turbo_Vision) library.  For those " +
+"wishing to use the actual C++ Turbo Vision library, see [Sergio " +
+"Sigala's updated version](http://tvision.sourceforge.net/) that runs " +
+"on many more platforms.\n" +
+"\n" +
+"This library is licensed MIT.  See the file LICENSE for the full license " +
+"for the details.\n");
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the text field
+            TResizeEvent textSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                event.getWidth() - 4, event.getHeight() - 6);
+            textField.onResize(textSize);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(event);
+        }
+    }
+
+    /**
+     * Play with menu items.
+     */
+    public void onFocus() {
+        getApplication().enableMenuItem(2001);
+        getApplication().disableMenuItem(TMenu.MID_SHELL);
+        getApplication().disableMenuItem(TMenu.MID_EXIT);
+    }
+
+    /**
+     * Called by application.switchWindow() when another window gets the
+     * focus.
+     */
+    public void onUnfocus() {
+        getApplication().disableMenuItem(2001);
+        getApplication().enableMenuItem(TMenu.MID_SHELL);
+        getApplication().enableMenuItem(TMenu.MID_EXIT);
+    }
+
+}
diff --git a/src/jexer/demos/DemoTextWindow.properties b/src/jexer/demos/DemoTextWindow.properties
new file mode 100644 (file)
index 0000000..873a56f
--- /dev/null
@@ -0,0 +1,12 @@
+windowTitle=Text Area
+
+statusBar=Reflowable text window
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
+
+left=&Left
+center=&Center
+right=&Right
+full=&Full
diff --git a/src/jexer/demos/DemoTreeViewWindow.java b/src/jexer/demos/DemoTreeViewWindow.java
new file mode 100644 (file)
index 0000000..4798951
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * 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.demos;
+
+import java.io.IOException;
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.event.TResizeEvent;
+import jexer.ttree.TDirectoryTreeItem;
+import jexer.ttree.TTreeViewWidget;
+import static jexer.TCommand.*;
+import static jexer.TKeypress.*;
+
+/**
+ * This window demonstates the TTreeView widget.
+ */
+public class DemoTreeViewWindow extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTreeViewWindow.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto my TTreeView so I can resize it with the window.
+     */
+    private TTreeViewWidget treeView;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     * @throws IOException if a java.io operation throws
+     */
+    public DemoTreeViewWindow(final TApplication parent) throws IOException {
+        super(parent, i18n.getString("windowTitle"), 0, 0, 44, 16,
+            TWindow.RESIZABLE);
+
+        // Load the treeview with "stuff"
+        treeView = addTreeViewWidget(1, 1, 40, 12);
+        new TDirectoryTreeItem(treeView, ".", true);
+
+        statusBar = newStatusBar(i18n.getString("statusBar"));
+        statusBar.addShortcutKeypress(kbF1, cmHelp,
+            i18n.getString("statusBarHelp"));
+        statusBar.addShortcutKeypress(kbF2, cmShell,
+            i18n.getString("statusBarShell"));
+        statusBar.addShortcutKeypress(kbF3, cmOpen,
+            i18n.getString("statusBarOpen"));
+        statusBar.addShortcutKeypress(kbF10, cmExit,
+            i18n.getString("statusBarExit"));
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the treeView field
+            TResizeEvent treeSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                resize.getWidth() - 4, resize.getHeight() - 4);
+            treeView.onResize(treeSize);
+            return;
+        }
+
+        // Pass to children instead
+        for (TWidget widget: getChildren()) {
+            widget.onResize(resize);
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DemoTreeViewWindow.properties b/src/jexer/demos/DemoTreeViewWindow.properties
new file mode 100644 (file)
index 0000000..d63b24e
--- /dev/null
@@ -0,0 +1,7 @@
+windowTitle=Tree View
+
+statusBar=Treeview demonstration
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
diff --git a/src/jexer/demos/DesktopDemo.java b/src/jexer/demos/DesktopDemo.java
new file mode 100644 (file)
index 0000000..520f5b0
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * 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.demos;
+
+import jexer.*;
+
+/**
+ * The modified desktop.
+ */
+public class DesktopDemo extends TDesktop {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, draw the hatch.  Note package private access.
+     */
+    boolean drawHatch = true;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent application
+     */
+    public DesktopDemo(final TApplication parent) {
+        super(parent);
+    }
+
+    // ------------------------------------------------------------------------
+    // TDesktop ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The default TDesktop draws a hatch character across everything.  This
+     * version is selectable.
+     */
+    @Override
+    public void draw() {
+        if (drawHatch) {
+            super.draw();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DesktopDemoApplication.java b/src/jexer/demos/DesktopDemoApplication.java
new file mode 100644 (file)
index 0000000..73d0c5f
--- /dev/null
@@ -0,0 +1,268 @@
+/*
+ * 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.demos;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ResourceBundle;
+import java.util.Scanner;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.TWindow;
+import jexer.event.TMenuEvent;
+import jexer.menu.TMenu;
+
+/**
+ * The demo application itself.
+ */
+public class DesktopDemoApplication extends TApplication {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(DesktopDemoApplication.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param backendType one of the TApplication.BackendType values
+     * @throws Exception if TApplication can't instantiate the Backend.
+     */
+    public DesktopDemoApplication(final BackendType backendType) throws Exception {
+        super(backendType);
+        addAllWidgets();
+        getBackend().setTitle(i18n.getString("applicationTitle"));
+    }
+
+    // ------------------------------------------------------------------------
+    // TApplication -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle menu events.
+     *
+     * @param menu menu event
+     * @return if true, the event was processed and should not be passed onto
+     * a window
+     */
+    @Override
+    public boolean onMenu(final TMenuEvent menu) {
+
+        if (menu.getId() == TMenu.MID_OPEN_FILE) {
+            try {
+                String filename = fileOpenBox(".");
+                 if (filename != null) {
+                     try {
+                         File file = new File(filename);
+                         StringBuilder fileContents = new StringBuilder();
+                         Scanner scanner = new Scanner(file);
+                         String EOL = System.getProperty("line.separator");
+
+                         try {
+                             while (scanner.hasNextLine()) {
+                                 fileContents.append(scanner.nextLine() + EOL);
+                             }
+                             new DemoTextWindow(this, filename,
+                                 fileContents.toString());
+                         } finally {
+                             scanner.close();
+                         }
+                     } catch (IOException e) {
+                         e.printStackTrace();
+                     }
+                 }
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+            return true;
+        }
+        return super.onMenu(menu);
+    }
+
+    // ------------------------------------------------------------------------
+    // DesktopDemoApplication -------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Add all the widgets of the demo.
+     */
+    private void addAllWidgets() {
+
+        // Add the menus
+        addFileMenu();
+        addEditMenu();
+        addWindowMenu();
+        addHelpMenu();
+
+        final DesktopDemo desktop = new DesktopDemo(this);
+        setDesktop(desktop);
+
+        desktop.addButton(i18n.getString("removeHatch"), 2, 5,
+            new TAction() {
+                public void DO() {
+                    desktop.drawHatch = false;
+                }
+            }
+        );
+        desktop.addButton(i18n.getString("showHatch"), 2, 8,
+            new TAction() {
+                public void DO() {
+                    desktop.drawHatch = true;
+                }
+            }
+        );
+
+        final TWindow windowA = addWindow(i18n.getString("windowATitle"),
+            25, 14);
+        final TWindow windowB = addWindow(i18n.getString("windowBTitle"),
+            25, 14);
+        windowA.addButton(i18n.getString("showWindowB"), 2, 2,
+            new TAction() {
+                public void DO() {
+                    windowB.show();
+                }
+            }
+        );
+        windowA.addButton(i18n.getString("hideWindowB"), 2, 4,
+            new TAction() {
+                public void DO() {
+                    windowB.hide();
+                }
+            }
+        );
+        windowA.addButton(i18n.getString("maximizeWindowB"), 2, 6,
+            new TAction() {
+                public void DO() {
+                    windowB.maximize();
+                }
+            }
+        );
+        windowA.addButton(i18n.getString("restoreWindowB"), 2, 8,
+            new TAction() {
+                public void DO() {
+                    windowB.restore();
+                }
+            }
+        );
+        windowB.addButton(i18n.getString("showWindowA"), 2, 2,
+            new TAction() {
+                public void DO() {
+                    windowA.show();
+                }
+            }
+        );
+        windowB.addButton(i18n.getString("hideWindowA"), 2, 4,
+            new TAction() {
+                public void DO() {
+                    windowA.hide();
+                }
+            }
+        );
+        windowB.addButton(i18n.getString("maximizeWindowA"), 2, 6,
+            new TAction() {
+                public void DO() {
+                    windowA.maximize();
+                }
+            }
+        );
+        windowB.addButton(i18n.getString("restoreWindowA"), 2, 8,
+            new TAction() {
+                public void DO() {
+                    windowA.restore();
+                }
+            }
+        );
+
+        desktop.addButton(i18n.getString("showWindowB"), 25, 2,
+            new TAction() {
+                public void DO() {
+                    windowB.show();
+                }
+            }
+        );
+        desktop.addButton(i18n.getString("hideWindowB"), 25, 5,
+            new TAction() {
+                public void DO() {
+                    windowB.hide();
+                }
+            }
+        );
+        desktop.addButton(i18n.getString("showWindowA"), 25, 8,
+            new TAction() {
+                public void DO() {
+                    windowA.show();
+                }
+            }
+        );
+        desktop.addButton(i18n.getString("hideWindowA"), 25, 11,
+            new TAction() {
+                public void DO() {
+                    windowA.hide();
+                }
+            }
+        );
+        desktop.addButton(i18n.getString("createWindowC"), 25, 15,
+            new TAction() {
+                public void DO() {
+                    final TWindow windowC = desktop.getApplication().addWindow(
+                        i18n.getString("windowCTitle"), 30, 20,
+                        TWindow.NOCLOSEBOX);
+                    windowC.addButton(i18n.getString("closeMe"), 5, 5,
+                        new TAction() {
+                            public void DO() {
+                                windowC.close();
+                            }
+                        }
+                    );
+                }
+            }
+        );
+
+        desktop.addButton(i18n.getString("enableFFM"), 25, 18,
+            new TAction() {
+                public void DO() {
+                    DesktopDemoApplication.this.setFocusFollowsMouse(true);
+                }
+            }
+        );
+        desktop.addButton(i18n.getString("disableFFM"), 25, 21,
+            new TAction() {
+                public void DO() {
+                    DesktopDemoApplication.this.setFocusFollowsMouse(false);
+                }
+            }
+        );
+    }
+
+}
diff --git a/src/jexer/demos/DesktopDemoApplication.properties b/src/jexer/demos/DesktopDemoApplication.properties
new file mode 100644 (file)
index 0000000..85f7435
--- /dev/null
@@ -0,0 +1,19 @@
+applicationTitle=Demo Application
+
+removeHatch=Remove HATCH
+showHatch=Show HATCH
+closeMe=Close Me
+createWindowC=Create Window C
+disableFFM=Disable focusFollowsMouse
+enableFFM=Enable focusFollowsMouse
+hideWindowA=Hide Window A
+hideWindowB=Hide Window B
+maximizeWindowA=Maximize Window A
+maximizeWindowB=Maximize Window B
+restoreWindowA=Restore Window A
+restoreWindowB=Restore Window B
+showWindowA=Show Window A
+showWindowB=Show Window B
+windowATitle=Window A
+windowBTitle=Window B
+windowCTitle=Window C
diff --git a/src/jexer/demos/package-info.java b/src/jexer/demos/package-info.java
new file mode 100644 (file)
index 0000000..1305cdd
--- /dev/null
@@ -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
+ */
+
+/**
+ * Demonstration programs.
+ */
+package jexer.demos;
diff --git a/src/jexer/event/TCommandEvent.java b/src/jexer/event/TCommandEvent.java
new file mode 100644 (file)
index 0000000..60f6385
--- /dev/null
@@ -0,0 +1,128 @@
+/*
+ * 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.event;
+
+import jexer.TCommand;
+
+/**
+ * This class encapsulates a user command event.  User commands can be
+ * generated by menu actions, keyboard accelerators, and other UI elements.
+ * Commands can operate on both the application and individual widgets.
+ */
+public class TCommandEvent extends TInputEvent {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Command dispatched.
+     */
+    private TCommand cmd;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public contructor.
+     *
+     * @param cmd the TCommand dispatched
+     */
+    public TCommandEvent(final TCommand cmd) {
+        this.cmd = cmd;
+    }
+
+    // ------------------------------------------------------------------------
+    // TInputEvent ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another TCommandEvent or TCommand instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public boolean equals(final Object rhs) {
+        if (!(rhs instanceof TCommandEvent)
+            && !(rhs instanceof TCommand)
+        ) {
+            return false;
+        }
+
+        if (rhs instanceof TCommandEvent) {
+            TCommandEvent that = (TCommandEvent) rhs;
+            return (cmd.equals(that.cmd)
+                && (getTime().equals(that.getTime())));
+        }
+
+        TCommand that = (TCommand) rhs;
+        return (cmd.equals(that));
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        int A = 13;
+        int B = 23;
+        int hash = A;
+        hash = (B * hash) + getTime().hashCode();
+        hash = (B * hash) + cmd.hashCode();
+        return hash;
+    }
+
+    /**
+     * Make human-readable description of this TCommandEvent.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return String.format("CommandEvent: %s", cmd.toString());
+    }
+
+    // ------------------------------------------------------------------------
+    // TCommandEvent ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get TCommand.
+     *
+     * @return the TCommand
+     */
+    public TCommand getCmd() {
+        return cmd;
+    }
+
+}
diff --git a/src/jexer/event/TInputEvent.java b/src/jexer/event/TInputEvent.java
new file mode 100644 (file)
index 0000000..220512f
--- /dev/null
@@ -0,0 +1,72 @@
+/*
+ * 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.event;
+
+import java.util.Date;
+
+/**
+ * This is the parent class of all events dispatched to the UI.
+ */
+public abstract class TInputEvent {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Time at which event was generated.
+     */
+    private Date time;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Protected contructor.
+     */
+    protected TInputEvent() {
+        // Save the current time
+        time = new Date();
+    }
+
+    // ------------------------------------------------------------------------
+    // TInputEvent ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get time.
+     *
+     * @return the time that this event was generated
+     */
+    public final Date getTime() {
+        return time;
+    }
+
+}
diff --git a/src/jexer/event/TKeypressEvent.java b/src/jexer/event/TKeypressEvent.java
new file mode 100644 (file)
index 0000000..79b28f2
--- /dev/null
@@ -0,0 +1,167 @@
+/*
+ * 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.event;
+
+import jexer.TKeypress;
+
+/**
+ * This class encapsulates a keyboard input event.
+ */
+public class TKeypressEvent extends TInputEvent {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Keystroke received.
+     */
+    private TKeypress key;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public contructor.
+     *
+     * @param key the TKeypress received
+     */
+    public TKeypressEvent(final TKeypress key) {
+        this.key = key;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param isKey is true, this is a function key
+     * @param fnKey the function key code (only valid if isKey is true)
+     * @param ch the character (only valid if fnKey is false)
+     * @param alt if true, ALT was pressed with this keystroke
+     * @param ctrl if true, CTRL was pressed with this keystroke
+     * @param shift if true, SHIFT was pressed with this keystroke
+     */
+    public TKeypressEvent(final boolean isKey, final int fnKey, final int ch,
+        final boolean alt, final boolean ctrl, final boolean shift) {
+
+        this.key = new TKeypress(isKey, fnKey, ch, alt, ctrl, shift);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param key the TKeypress received
+     * @param alt if true, ALT was pressed with this keystroke
+     * @param ctrl if true, CTRL was pressed with this keystroke
+     * @param shift if true, SHIFT was pressed with this keystroke
+     */
+    public TKeypressEvent(final TKeypress key,
+        final boolean alt, final boolean ctrl, final boolean shift) {
+
+        this.key = new TKeypress(key.isFnKey(), key.getKeyCode(), key.getChar(),
+            alt, ctrl, shift);
+    }
+
+    // ------------------------------------------------------------------------
+    // TInputEvent ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Comparison check.  All fields must match to return true.
+     *
+     * @param rhs another TKeypressEvent or TKeypress instance
+     * @return true if all fields are equal
+     */
+    @Override
+    public boolean equals(final Object rhs) {
+        if (!(rhs instanceof TKeypressEvent)
+            && !(rhs instanceof TKeypress)
+        ) {
+            return false;
+        }
+
+        if (rhs instanceof TKeypressEvent) {
+            TKeypressEvent that = (TKeypressEvent) rhs;
+            return (key.equals(that.key)
+                && (getTime().equals(that.getTime())));
+        }
+
+        TKeypress that = (TKeypress) rhs;
+        return (key.equals(that));
+    }
+
+    /**
+     * Hashcode uses all fields in equals().
+     *
+     * @return the hash
+     */
+    @Override
+    public int hashCode() {
+        int A = 13;
+        int B = 23;
+        int hash = A;
+        hash = (B * hash) + getTime().hashCode();
+        hash = (B * hash) + key.hashCode();
+        return hash;
+    }
+
+    /**
+     * Make human-readable description of this TKeypressEvent.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return String.format("Keypress: %s", key.toString());
+    }
+
+    // ------------------------------------------------------------------------
+    // TKeypressEvent ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get keystroke.
+     *
+     * @return keystroke
+     */
+    public TKeypress getKey() {
+        return key;
+    }
+
+    /**
+     * Create a duplicate instance.
+     *
+     * @return duplicate intance
+     */
+    public TKeypressEvent dup() {
+        TKeypressEvent keypress = new TKeypressEvent(key.dup());
+        return keypress;
+    }
+
+}
diff --git a/src/jexer/event/TMenuEvent.java b/src/jexer/event/TMenuEvent.java
new file mode 100644 (file)
index 0000000..e2ff7c7
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * 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.event;
+
+/**
+ * This class encapsulates a menu selection event.
+ * TApplication.getMenuItem(id) can be used to obtain the TMenuItem itself,
+ * say for setting enabled/disabled/checked/etc.
+ */
+public class TMenuEvent extends TInputEvent {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * MenuItem ID.
+     */
+    private int id;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public contructor.
+     *
+     * @param id the MenuItem ID
+     */
+    public TMenuEvent(final int id) {
+        this.id = id;
+    }
+
+    // ------------------------------------------------------------------------
+    // TInputEvent ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Make human-readable description of this TMenuEvent.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return String.format("MenuEvent: %d", id);
+    }
+
+    // ------------------------------------------------------------------------
+    // TMenuEvent -------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the MenuItem ID.
+     *
+     * @return the ID
+     */
+    public int getId() {
+        return id;
+    }
+
+}
diff --git a/src/jexer/event/TMouseEvent.java b/src/jexer/event/TMouseEvent.java
new file mode 100644 (file)
index 0000000..e529898
--- /dev/null
@@ -0,0 +1,373 @@
+/*
+ * 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.event;
+
+/**
+ * This class encapsulates several kinds of mouse input events.  Note that
+ * the relative (x,y) ARE MUTABLE: TWidget's onMouse() handlers perform that
+ * update during event dispatching.
+ */
+public class TMouseEvent extends TInputEvent {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The type of event generated.
+     */
+    public enum Type {
+        /**
+         * Mouse motion.  X and Y will have screen coordinates.
+         */
+        MOUSE_MOTION,
+
+        /**
+         * Mouse button down.  X and Y will have screen coordinates.
+         */
+        MOUSE_DOWN,
+
+        /**
+         * Mouse button up.  X and Y will have screen coordinates.
+         */
+        MOUSE_UP,
+
+        /**
+         * Mouse double-click.  X and Y will have screen coordinates.
+         */
+        MOUSE_DOUBLE_CLICK
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Type of event, one of MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN.
+     */
+    private Type type;
+
+    /**
+     * Mouse X - relative coordinates.
+     */
+    private int x;
+
+    /**
+     * Mouse Y - relative coordinates.
+     */
+    private int y;
+
+    /**
+     * Mouse X - absolute screen coordinates.
+     */
+    private int absoluteX;
+
+    /**
+     * Mouse Y - absolute screen coordinate.
+     */
+    private int absoluteY;
+
+    /**
+     * Mouse button 1 (left button).
+     */
+    private boolean mouse1;
+
+    /**
+     * Mouse button 2 (right button).
+     */
+    private boolean mouse2;
+
+    /**
+     * Mouse button 3 (middle button).
+     */
+    private boolean mouse3;
+
+    /**
+     * Mouse wheel UP (button 4).
+     */
+    private boolean mouseWheelUp;
+
+    /**
+     * Mouse wheel DOWN (button 5).
+     */
+    private boolean mouseWheelDown;
+
+    /**
+     * Keyboard modifier ALT.
+     */
+    private boolean alt;
+
+    /**
+     * Keyboard modifier CTRL.
+     */
+    private boolean ctrl;
+
+    /**
+     * Keyboard modifier SHIFT.
+     */
+    private boolean shift;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public contructor.
+     *
+     * @param type the type of event, MOUSE_MOTION, MOUSE_DOWN, or MOUSE_UP
+     * @param x relative column
+     * @param y relative row
+     * @param absoluteX absolute column
+     * @param absoluteY absolute row
+     * @param mouse1 if true, left button is down
+     * @param mouse2 if true, right button is down
+     * @param mouse3 if true, middle button is down
+     * @param mouseWheelUp if true, mouse wheel (button 4) is down
+     * @param mouseWheelDown if true, mouse wheel (button 5) is down
+     * @param alt if true, ALT was pressed with this mouse event
+     * @param ctrl if true, CTRL was pressed with this mouse event
+     * @param shift if true, SHIFT was pressed with this mouse event
+     */
+    public TMouseEvent(final Type type, final int x, final int y,
+        final int absoluteX, final int absoluteY,
+        final boolean mouse1, final boolean mouse2, final boolean mouse3,
+        final boolean mouseWheelUp, final boolean mouseWheelDown,
+        final boolean alt, final boolean ctrl, final boolean shift) {
+
+        this.type               = type;
+        this.x                  = x;
+        this.y                  = y;
+        this.absoluteX          = absoluteX;
+        this.absoluteY          = absoluteY;
+        this.mouse1             = mouse1;
+        this.mouse2             = mouse2;
+        this.mouse3             = mouse3;
+        this.mouseWheelUp       = mouseWheelUp;
+        this.mouseWheelDown     = mouseWheelDown;
+        this.alt                = alt;
+        this.ctrl               = ctrl;
+        this.shift              = shift;
+    }
+
+    // ------------------------------------------------------------------------
+    // TMouseEvent ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get type.
+     *
+     * @return type
+     */
+    public Type getType() {
+        return type;
+    }
+
+    /**
+     * Get x.
+     *
+     * @return x
+     */
+    public int getX() {
+        return x;
+    }
+
+    /**
+     * Set x.
+     *
+     * @param x new relative X value
+     * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+     * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+     * @see jexer.TWidget#onMouseMotion(TMouseEvent mouse)
+     */
+    public void setX(final int x) {
+        this.x = x;
+    }
+
+    /**
+     * Get y.
+     *
+     * @return y
+     */
+    public int getY() {
+        return y;
+    }
+
+    /**
+     * Set y.
+     *
+     * @param y new relative Y value
+     * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+     * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+     * @see jexer.TWidget#onMouseMotion(TMouseEvent mouse)
+     */
+    public void setY(final int y) {
+        this.y = y;
+    }
+
+    /**
+     * Get absoluteX.
+     *
+     * @return absoluteX
+     */
+    public int getAbsoluteX() {
+        return absoluteX;
+    }
+
+    /**
+     * Set absoluteX.
+     *
+     * @param absoluteX the new value
+     */
+    public void setAbsoluteX(final int absoluteX) {
+        this.absoluteX = absoluteX;
+    }
+
+    /**
+     * Get absoluteY.
+     *
+     * @return absoluteY
+     */
+    public int getAbsoluteY() {
+        return absoluteY;
+    }
+
+    /**
+     * Set absoluteY.
+     *
+     * @param absoluteY the new value
+     */
+    public void setAbsoluteY(final int absoluteY) {
+        this.absoluteY = absoluteY;
+    }
+
+    /**
+     * Get mouse1.
+     *
+     * @return mouse1
+     */
+    public boolean isMouse1() {
+        return mouse1;
+    }
+
+    /**
+     * Get mouse2.
+     *
+     * @return mouse2
+     */
+    public boolean isMouse2() {
+        return mouse2;
+    }
+
+    /**
+     * Get mouse3.
+     *
+     * @return mouse3
+     */
+    public boolean isMouse3() {
+        return mouse3;
+    }
+
+    /**
+     * Get mouseWheelUp.
+     *
+     * @return mouseWheelUp
+     */
+    public boolean isMouseWheelUp() {
+        return mouseWheelUp;
+    }
+
+    /**
+     * Get mouseWheelDown.
+     *
+     * @return mouseWheelDown
+     */
+    public boolean isMouseWheelDown() {
+        return mouseWheelDown;
+    }
+
+    /**
+     * Getter for ALT.
+     *
+     * @return alt value
+     */
+    public boolean isAlt() {
+        return alt;
+    }
+
+    /**
+     * Getter for CTRL.
+     *
+     * @return ctrl value
+     */
+    public boolean isCtrl() {
+        return ctrl;
+    }
+
+    /**
+     * Getter for SHIFT.
+     *
+     * @return shift value
+     */
+    public boolean isShift() {
+        return shift;
+    }
+
+    /**
+     * Create a duplicate instance.
+     *
+     * @return duplicate intance
+     */
+    public TMouseEvent dup() {
+        TMouseEvent mouse = new TMouseEvent(type, x, y, absoluteX, absoluteY,
+            mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown,
+            alt, ctrl, shift);
+
+        return mouse;
+    }
+
+    /**
+     * Make human-readable description of this TMouseEvent.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return String.format("Mouse: %s x %d y %d absoluteX %d absoluteY %d 1 %s 2 %s 3 %s DOWN %s UP %s ALT %s CTRL %s SHIFT %s",
+            type,
+            x, y,
+            absoluteX, absoluteY,
+            mouse1,
+            mouse2,
+            mouse3,
+            mouseWheelUp,
+            mouseWheelDown,
+            alt, ctrl, shift);
+    }
+
+}
diff --git a/src/jexer/event/TResizeEvent.java b/src/jexer/event/TResizeEvent.java
new file mode 100644 (file)
index 0000000..ff95710
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * 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.event;
+
+/**
+ * This class encapsulates a screen or window resize event.
+ */
+public class TResizeEvent extends TInputEvent {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Resize events can be generated for either a total screen resize or a
+     * widget/window resize.
+     */
+    public enum Type {
+        /**
+         * The entire screen size changed.
+         */
+        SCREEN,
+
+        /**
+         * A widget was resized.
+         */
+        WIDGET
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The type of resize.
+     */
+    private Type type;
+
+    /**
+     * New width.
+     */
+    private int width;
+
+    /**
+     * New height.
+     */
+    private int height;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public contructor.
+     *
+     * @param type the Type of resize, Screen or Widget
+     * @param width the new width
+     * @param height the new height
+     */
+    public TResizeEvent(final Type type, final int width, final int height) {
+        this.type   = type;
+        this.width  = width;
+        this.height = height;
+    }
+
+    // ------------------------------------------------------------------------
+    // TResizeEvent -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get resize type.
+     *
+     * @return SCREEN or WIDGET
+     */
+    public Type getType() {
+        return type;
+    }
+
+    /**
+     * Get the new width.
+     *
+     * @return width
+     */
+    public int getWidth() {
+        return width;
+    }
+
+    /**
+     * Get the new height.
+     *
+     * @return height
+     */
+    public int getHeight() {
+        return height;
+    }
+
+    /**
+     * Make human-readable description of this TResizeEvent.
+     *
+     * @return displayable String
+     */
+    @Override
+    public String toString() {
+        return String.format("Resize: %s width = %d height = %d",
+            type, width, height);
+    }
+
+}
diff --git a/src/jexer/event/package-info.java b/src/jexer/event/package-info.java
new file mode 100644 (file)
index 0000000..e4541a3
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * 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
+ */
+
+/**
+ * Events that are generated by both end-user I/O (keyboard/mouse) and other
+ * UI elements (menu/resize).
+ */
+package jexer.event;
diff --git a/src/jexer/examples/imgls b/src/jexer/examples/imgls
new file mode 100755 (executable)
index 0000000..99bbb9c
--- /dev/null
@@ -0,0 +1,64 @@
+#!/bin/bash
+
+# This is a modified version of the 'imgls' from iTerm2 located at
+# https://iterm2.com/utilities/imgls, modified to emit images with the
+# Jexer image protocol.
+
+# tmux requires unrecognized OSC sequences to be wrapped with DCS tmux;
+# <sequence> ST, and for all ESCs in <sequence> to be replaced with ESC ESC. It
+# only accepts ESC backslash for ST.
+function print_osc() {
+    if [ x"$TERM" = "xscreen" ] ; then
+        printf "\033Ptmux;\033\033]"
+    else
+        printf "\033]"
+    fi
+}
+
+function check_dependency() {
+  if ! (builtin command -V "$1" > /dev/null 2>& 1); then
+    echo "imgcat: missing dependency: can't find $1" 1>& 2
+    exit 1
+  fi
+}
+
+# More of the tmux workaround described above.
+function print_st() {
+    if [ x"$TERM" = "xscreen" ] ; then
+        printf "\a\033\\"
+    else
+        printf "\a"
+    fi
+}
+
+function list_file() {
+  fn=$1
+  test -f "$fn" || return 0
+
+  if [ "${fn: -4}" == ".png" ]; then
+      print_osc
+      printf '444;1;1;'
+      base64 < "$fn"
+      print_st
+  elif [ "${fn: -4}" == ".jpg" ]; then
+      print_osc
+      printf '444;2;1;'
+      base64 < "$fn"
+      print_st
+  fi
+}
+
+check_dependency base64
+
+if [ $# -eq 0 ]; then
+  for fn in *
+  do
+     list_file "$fn"
+  done < <(ls -ls)
+else
+  for fn in "$@"
+  do
+     list_file "$fn"
+  done
+fi
+
diff --git a/src/jexer/help/HelpFile.java b/src/jexer/help/HelpFile.java
new file mode 100644 (file)
index 0000000..7a6f49e
--- /dev/null
@@ -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<String, Topic> topicsByTitle;
+
+    /**
+     * The map of topics by index key term.
+     */
+    private HashMap<String, Topic> 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<String, Topic>();
+        topicsByTerm = new HashMap<String, Topic>();
+
+        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<Topic> allTopics = new ArrayList<Topic>();
+        allTopics.addAll(topicsByTitle.values());
+        Collections.sort(allTopics);
+
+        List<Topic> results = new ArrayList<Topic>();
+        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<Link> links = new ArrayList<Link>();
+        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<Topic> allTopics = new ArrayList<Topic>();
+        allTopics.addAll(topicsByTitle.values());
+        Collections.sort(allTopics);
+
+        StringBuilder text = new StringBuilder();
+        int wordIndex = 0;
+        List<Link> links = new ArrayList<Link>();
+        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<Topic> allTopics = new ArrayList<Topic>();
+        allTopics.addAll(topicsByTitle.values());
+
+        HashMap<String, ArrayList<Topic>> allKeys;
+        allKeys = new HashMap<String, ArrayList<Topic>>();
+        for (Topic topic: allTopics) {
+            for (String key: topic.getIndexKeys()) {
+                key = key.toLowerCase();
+                ArrayList<Topic> topics = allKeys.get(key);
+                if (topics == null) {
+                    topics = new ArrayList<Topic>();
+                    allKeys.put(key, topics);
+                }
+                topics.add(topic);
+            }
+        }
+        List<String> keys = new ArrayList<String>();
+        keys.addAll(allKeys.keySet());
+        Collections.sort(keys);
+
+        StringBuilder text = new StringBuilder();
+        int wordIndex = 0;
+        List<Link> links = new ArrayList<Link>();
+
+        for (String key: keys) {
+            List<Topic> 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 (file)
index 0000000..803961b
--- /dev/null
@@ -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 (file)
index 0000000..665381c
--- /dev/null
@@ -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 (file)
index 0000000..2e0afcf
--- /dev/null
@@ -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<TParagraph> 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<TParagraph>();
+
+        // Add title paragraph at top.  We explicitly set the separator to
+        // false to achieve the underscore effect.
+        List<TWord> title = new ArrayList<TWord>();
+        title.add(new TWord(topic.getTitle(), null));
+        TParagraph titleParagraph = new TParagraph(this, title);
+        titleParagraph.separator = false;
+        paragraphs.add(titleParagraph);
+        title = new ArrayList<TWord>();
+        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<TWord> words = new ArrayList<TWord>();
+            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 (file)
index 0000000..04559da
--- /dev/null
@@ -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<TWord> 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<TWord> 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 (file)
index 0000000..d46a22e
--- /dev/null
@@ -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 (file)
index 0000000..8c0bc71
--- /dev/null
@@ -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<Topic> {
+
+    /**
+     * 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<String> indexKeys = new HashSet<String>();
+
+    /**
+     * The links in this topic.
+     */
+    private List<Link> links = new ArrayList<Link>();
+
+    // ------------------------------------------------------------------------
+    // 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<Link> 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<String> getIndexKeys() {
+        return indexKeys;
+    }
+
+    /**
+     * Get the links.
+     *
+     * @return the links
+     */
+    public List<Link> 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 (file)
index 0000000..1c8de6f
--- /dev/null
@@ -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 (file)
index 0000000..409c370
--- /dev/null
@@ -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;
diff --git a/src/jexer/io/ReadTimeoutException.java b/src/jexer/io/ReadTimeoutException.java
new file mode 100644 (file)
index 0000000..8c6371e
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * 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.io;
+
+import java.io.IOException;
+
+/**
+ * ReadTimeoutException is thrown by TimeoutInputStream.read() when bytes are
+ * not available within the timeout specified.
+ */
+public class ReadTimeoutException extends IOException {
+
+    /**
+     * Serializable version.
+     */
+    private static final long serialVersionUID = 1;
+
+    /**
+     * Construct an instance with a message.
+     *
+     * @param msg exception text
+     */
+    public ReadTimeoutException(String msg) {
+        super(msg);
+    }
+}
diff --git a/src/jexer/io/TimeoutInputStream.java b/src/jexer/io/TimeoutInputStream.java
new file mode 100644 (file)
index 0000000..70faff4
--- /dev/null
@@ -0,0 +1,393 @@
+/*
+ * 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.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * This class provides an optional millisecond timeout on its read()
+ * operations.  This permits callers to bail out rather than block.
+ */
+public class TimeoutInputStream extends InputStream {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The wrapped stream.
+     */
+    private InputStream stream;
+
+    /**
+     * The timeout value in millis.  If it takes longer than this for bytes
+     * to be available for read then a ReadTimeoutException is thrown.  A
+     * value of 0 means to block as a normal InputStream would.
+     */
+    private int timeoutMillis;
+
+    /**
+     * If true, the current read() will timeout soon.
+     */
+    private volatile boolean cancel = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor, at the default timeout of 10000 millis (10
+     * seconds).
+     *
+     * @param stream the wrapped InputStream
+     */
+    public TimeoutInputStream(final InputStream stream) {
+        this.stream             = stream;
+        this.timeoutMillis      = 10000;
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param stream the wrapped InputStream
+     * @param timeoutMillis the timeout value in millis.  If it takes longer
+     * than this for bytes to be available for read then a
+     * ReadTimeoutException is thrown.  A value of 0 means to block as a
+     * normal InputStream would.
+     */
+    public TimeoutInputStream(final InputStream stream,
+        final int timeoutMillis) {
+
+        if (timeoutMillis < 0) {
+            throw new IllegalArgumentException("Invalid timeoutMillis value, " +
+                "must be >= 0");
+        }
+
+        this.stream             = stream;
+        this.timeoutMillis      = timeoutMillis;
+    }
+
+    // ------------------------------------------------------------------------
+    // InputStream ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Reads the next byte of data from the input stream.
+     *
+     * @return the next byte of data, or -1 if there is no more data because
+     * the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int read() throws IOException {
+
+        if (timeoutMillis == 0) {
+            // Block on the read().
+            return stream.read();
+        }
+
+        if (stream.available() > 0) {
+            // A byte is available now, return it.
+            return stream.read();
+        }
+
+        // We will wait up to timeoutMillis to see if a byte is available.
+        // If not, we throw ReadTimeoutException.
+        long checkTime = System.currentTimeMillis();
+        while (stream.available() == 0) {
+            long now = System.currentTimeMillis();
+            synchronized (this) {
+                if ((now - checkTime > timeoutMillis) || (cancel == true)) {
+                    if (cancel == true) {
+                        cancel = false;
+                    }
+                    throw new ReadTimeoutException("Timeout on read(): " +
+                        (int) (now - checkTime) + " millis and still no data");
+                }
+            }
+            try {
+                // How long do we sleep for, eh?  For now we will go with 2
+                // millis.
+                Thread.sleep(2);
+            } catch (InterruptedException e) {
+                // SQUASH
+            }
+        }
+
+        if (stream.available() > 0) {
+            // A byte is available now, return it.
+            return stream.read();
+        }
+
+        throw new IOException("InputStream claimed a byte was available, but " +
+            "now it is not.  What is going on?");
+    }
+
+    /**
+     * Reads some number of bytes from the input stream and stores them into
+     * the buffer array b.
+     *
+     * @param b the buffer into which the data is read.
+     * @return the total number of bytes read into the buffer, or -1 if there
+     * is no more data because the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int read(final byte[] b) throws IOException {
+        if (timeoutMillis == 0) {
+            // Block on the read().
+            return stream.read(b);
+        }
+
+        int remaining = b.length;
+
+        if (stream.available() >= remaining) {
+            // Enough bytes are available now, return them.
+            return stream.read(b);
+        }
+
+        while (remaining > 0) {
+
+            // We will wait up to timeoutMillis to see if a byte is
+            // available.  If not, we throw ReadTimeoutException.
+            long checkTime = System.currentTimeMillis();
+            while (stream.available() == 0) {
+                if (remaining > 0) {
+                    return (b.length - remaining);
+                }
+
+                long now = System.currentTimeMillis();
+                synchronized (this) {
+                    if ((now - checkTime > timeoutMillis) || (cancel == true)) {
+                        if (cancel == true) {
+                            cancel = false;
+                        }
+                        throw new ReadTimeoutException("Timeout on read(): " +
+                            (int) (now - checkTime) + " millis and still no " +
+                            "data");
+                    }
+                }
+                try {
+                    // How long do we sleep for, eh?  For now we will go with
+                    // 2 millis.
+                    Thread.sleep(2);
+                } catch (InterruptedException e) {
+                    // SQUASH
+                }
+            }
+
+            if (stream.available() > 0) {
+                // At least one byte is available now, read it.
+                int n = stream.available();
+                if (remaining < n) {
+                    n = remaining;
+                }
+                int rc = stream.read(b, b.length - remaining, n);
+                if (rc == -1) {
+                    // This shouldn't happen.
+                    throw new IOException("InputStream claimed bytes were " +
+                        "available, but read() returned -1.  What is going " +
+                        "on?");
+                }
+                remaining -= rc;
+                if (remaining == 0) {
+                    return b.length;
+                }
+            }
+        }
+
+        throw new IOException("InputStream claimed all bytes were available, " +
+            "but now it is not.  What is going on?");
+    }
+
+    /**
+     * Reads up to len bytes of data from the input stream into an array of
+     * bytes.
+     *
+     * @param b the buffer into which the data is read.
+     * @param off the start offset in array b at which the data is written.
+     * @param len the maximum number of bytes to read.
+     * @return the total number of bytes read into the buffer, or -1 if there
+     * is no more data because the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int read(final byte[] b, final int off,
+        final int len) throws IOException {
+
+        if (timeoutMillis == 0) {
+            // Block on the read().
+            return stream.read(b, off, len);
+        }
+
+        int remaining = len;
+
+        if (stream.available() >= remaining) {
+            // Enough bytes are available now, return them.
+            return stream.read(b, off, remaining);
+        }
+
+        while (remaining > 0) {
+
+            // We will wait up to timeoutMillis to see if a byte is
+            // available.  If not, we throw ReadTimeoutException.
+            long checkTime = System.currentTimeMillis();
+            while (stream.available() == 0) {
+                if (remaining > 0) {
+                    return (len - remaining);
+                }
+
+                long now = System.currentTimeMillis();
+                synchronized (this) {
+                    if ((now - checkTime > timeoutMillis) || (cancel == true)) {
+                        if (cancel == true) {
+                            cancel = false;
+                        }
+                        throw new ReadTimeoutException("Timeout on read(): " +
+                            (int) (now - checkTime) + " millis and still no " +
+                            "data");
+                    }
+                }
+                try {
+                    // How long do we sleep for, eh?  For now we will go with
+                    // 2 millis.
+                    Thread.sleep(2);
+                } catch (InterruptedException e) {
+                    // SQUASH
+                }
+            }
+
+            if (stream.available() > 0) {
+                // At least one byte is available now, read it.
+                int n = stream.available();
+                if (remaining < n) {
+                    n = remaining;
+                }
+                int rc = stream.read(b, off + len - remaining, n);
+                if (rc == -1) {
+                    // This shouldn't happen.
+                    throw new IOException("InputStream claimed bytes were " +
+                        "available, but read() returned -1.  What is going " +
+                        "on?");
+                }
+                remaining -= rc;
+                if (remaining == 0) {
+                    return len;
+                }
+            }
+        }
+
+        throw new IOException("InputStream claimed all bytes were available, " +
+            "but now it is not.  What is going on?");
+    }
+
+    /**
+     * Returns an estimate of the number of bytes that can be read (or
+     * skipped over) from this input stream without blocking by the next
+     * invocation of a method for this input stream.
+     *
+     * @return an estimate of the number of bytes that can be read (or
+     * skipped over) from this input stream without blocking or 0 when it
+     * reaches the end of the input stream.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int available() throws IOException {
+        return stream.available();
+    }
+
+    /**
+     * Closes this input stream and releases any system resources associated
+     * with the stream.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void close() throws IOException {
+        stream.close();
+    }
+
+    /**
+     * Marks the current position in this input stream.
+     *
+     * @param readLimit the maximum limit of bytes that can be read before
+     * the mark position becomes invalid
+     */
+    @Override
+    public void mark(final int readLimit) {
+        stream.mark(readLimit);
+    }
+
+    /**
+     * Tests if this input stream supports the mark and reset methods.
+     *
+     * @return true if this stream instance supports the mark and reset
+     * methods; false otherwise
+     */
+    @Override
+    public boolean markSupported() {
+        return stream.markSupported();
+    }
+
+    /**
+     * Repositions this stream to the position at the time the mark method
+     * was last called on this input stream.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void reset() throws IOException {
+        stream.reset();
+    }
+
+    /**
+     * Skips over and discards n bytes of data from this input stream.
+     *
+     * @param n the number of bytes to be skipped
+     * @return the actual number of bytes skipped
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public long skip(final long n) throws IOException {
+        return stream.skip(n);
+    }
+
+    // ------------------------------------------------------------------------
+    // TimeoutInputStream -----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Request that the current read() operation timeout immediately.
+     */
+    public synchronized void cancelRead() {
+        cancel = true;
+    }
+
+}
diff --git a/src/jexer/io/package-info.java b/src/jexer/io/package-info.java
new file mode 100644 (file)
index 0000000..37ad2bb
--- /dev/null
@@ -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
+ */
+
+/**
+ * java.io subclasses.
+ */
+package jexer.io;
diff --git a/src/jexer/layout/BoxLayoutManager.java b/src/jexer/layout/BoxLayoutManager.java
new file mode 100644 (file)
index 0000000..057127f
--- /dev/null
@@ -0,0 +1,170 @@
+/*
+ * 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.layout;
+
+import java.util.ArrayList;
+
+import jexer.TWidget;
+import jexer.event.TResizeEvent;
+
+/**
+ * BoxLayoutManager repositions child widgets based on the order they are
+ * added to the parent widget and desired orientation.
+ */
+public class BoxLayoutManager implements LayoutManager {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, orient vertically.  If false, orient horizontally.
+     */
+    private boolean vertical = true;
+
+    /**
+     * Current width.
+     */
+    private int width = 0;
+
+    /**
+     * Current height.
+     */
+    private int height = 0;
+
+    /**
+     * Widgets being managed.
+     */
+    private ArrayList<TWidget> children = new ArrayList<TWidget>();
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param width the width of the parent widget
+     * @param height the height of the parent widget
+     * @param vertical if true, arrange widgets vertically
+     */
+    public BoxLayoutManager(final int width, final int height,
+        final boolean vertical) {
+
+        this.width = width;
+        this.height = height;
+        this.vertical = vertical;
+    }
+
+    // ------------------------------------------------------------------------
+    // LayoutManager ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Process the parent widget's resize event, and resize/reposition child
+     * widgets.
+     *
+     * @param resize resize event
+     */
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            width = resize.getWidth();
+            height = resize.getHeight();
+            layoutChildren();
+        }
+    }
+
+    /**
+     * Add a child widget to manage.
+     *
+     * @param child the widget to manage
+     */
+    public void add(final TWidget child) {
+        children.add(child);
+        layoutChildren();
+    }
+
+    /**
+     * Remove a child widget from those managed by this LayoutManager.
+     *
+     * @param child the widget to remove
+     */
+    public void remove(final TWidget child) {
+        children.remove(child);
+        layoutChildren();
+    }
+
+    /**
+     * Reset a child widget's original/preferred size.
+     *
+     * @param child the widget to manage
+     */
+    public void resetSize(final TWidget child) {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // BoxLayoutManager -------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Resize/reposition child widgets based on horizontal/vertical
+     * arrangement.
+     */
+    private void layoutChildren() {
+        if (children.size() == 0) {
+            return;
+        }
+        if (vertical) {
+            int widgetHeight = Math.max(1, height / children.size());
+            int leftoverHeight = height % children.size();
+            for (int i = 0; i < children.size() - 1; i++) {
+                TWidget child = children.get(i);
+                child.setDimensions(child.getX(), i * widgetHeight,
+                    width, widgetHeight);
+            }
+            TWidget child = children.get(children.size() - 1);
+            child.setDimensions(child.getX(),
+                (children.size() - 1) * widgetHeight, width,
+                widgetHeight + leftoverHeight);
+        } else {
+            int widgetWidth = Math.max(1, width / children.size());
+            int leftoverWidth = width % children.size();
+            for (int i = 0; i < children.size() - 1; i++) {
+                TWidget child = children.get(i);
+                child.setDimensions(i * widgetWidth, child.getY(),
+                    widgetWidth, height);
+            }
+            TWidget child = children.get(children.size() - 1);
+            child.setDimensions((children.size() - 1) * widgetWidth,
+                child.getY(), widgetWidth + leftoverWidth, height);
+        }
+    }
+
+}
diff --git a/src/jexer/layout/LayoutManager.java b/src/jexer/layout/LayoutManager.java
new file mode 100644 (file)
index 0000000..5dbd1e8
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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.layout;
+
+import jexer.TWidget;
+import jexer.event.TResizeEvent;
+
+/**
+ * A LayoutManager provides automatic positioning and sizing of a TWidget's
+ * child TWidgets.
+ */
+public interface LayoutManager {
+
+    /**
+     * Process the parent widget's resize event, and resize/reposition child
+     * widgets.
+     *
+     * @param resize resize event
+     */
+    public void onResize(final TResizeEvent resize);
+
+    /**
+     * Add a child widget to manage.
+     *
+     * @param child the widget to manage
+     */
+    public void add(final TWidget child);
+
+    /**
+     * Remove a child widget from those managed by this LayoutManager.
+     *
+     * @param child the widget to remove
+     */
+    public void remove(final TWidget child);
+
+    /**
+     * Reset a child widget's original/preferred size.
+     *
+     * @param child the widget to manage
+     */
+    public void resetSize(final TWidget child);
+
+}
diff --git a/src/jexer/layout/StretchLayoutManager.java b/src/jexer/layout/StretchLayoutManager.java
new file mode 100644 (file)
index 0000000..4bcb0cf
--- /dev/null
@@ -0,0 +1,165 @@
+/*
+ * 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.layout;
+
+import java.awt.Rectangle;
+import java.util.HashMap;
+
+import jexer.TWidget;
+import jexer.event.TResizeEvent;
+
+/**
+ * StretchLayoutManager repositions child widgets based on their coordinates
+ * when added and the current widget size.
+ */
+public class StretchLayoutManager implements LayoutManager {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Current width.
+     */
+    private int width = 0;
+
+    /**
+     * Current height.
+     */
+    private int height = 0;
+
+    /**
+     * Original width.
+     */
+    private int originalWidth = 0;
+
+    /**
+     * Original height.
+     */
+    private int originalHeight = 0;
+
+    /**
+     * Map of widget to original dimensions.
+     */
+    private HashMap<TWidget, Rectangle> children = new HashMap<TWidget, Rectangle>();
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param width the width of the parent widget
+     * @param height the height of the parent widget
+     */
+    public StretchLayoutManager(final int width, final int height) {
+        originalWidth = width;
+        originalHeight = height;
+        this.width = width;
+        this.height = height;
+    }
+
+    // ------------------------------------------------------------------------
+    // LayoutManager ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Process the parent widget's resize event, and resize/reposition child
+     * widgets.
+     *
+     * @param resize resize event
+     */
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            width = resize.getWidth();
+            height = resize.getHeight();
+            layoutChildren();
+        }
+    }
+
+    /**
+     * Add a child widget to manage.
+     *
+     * @param child the widget to manage
+     */
+    public void add(final TWidget child) {
+        Rectangle rect = new Rectangle(child.getX(), child.getY(),
+            child.getWidth(), child.getHeight());
+        children.put(child, rect);
+        layoutChildren();
+    }
+
+    /**
+     * Remove a child widget from those managed by this LayoutManager.
+     *
+     * @param child the widget to remove
+     */
+    public void remove(final TWidget child) {
+        children.remove(child);
+        layoutChildren();
+    }
+
+    /**
+     * Reset a child widget's original/preferred size.
+     *
+     * @param child the widget to manage
+     */
+    public void resetSize(final TWidget child) {
+        // For this layout, adding is the same as replacing.
+        add(child);
+    }
+
+    // ------------------------------------------------------------------------
+    // StretchLayoutManager ---------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Resize/reposition child widgets based on difference between current
+     * dimensions and the original dimensions.
+     */
+    private void layoutChildren() {
+        double widthRatio = (double) width / originalWidth;
+        if (Math.abs(widthRatio) > Double.MAX_VALUE) {
+            widthRatio = 1;
+        }
+        double heightRatio = (double) height / originalHeight;
+        if (Math.abs(heightRatio) > Double.MAX_VALUE) {
+            heightRatio = 1;
+        }
+        for (TWidget child: children.keySet()) {
+            Rectangle rect = children.get(child);
+            child.setDimensions((int) (rect.getX() * widthRatio),
+                (int) (rect.getY() * heightRatio),
+                (int) (rect.getWidth() * widthRatio),
+                (int) (rect.getHeight() * heightRatio));
+        }
+    }
+
+}
diff --git a/src/jexer/layout/package-info.java b/src/jexer/layout/package-info.java
new file mode 100644 (file)
index 0000000..69887dd
--- /dev/null
@@ -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
+ */
+
+/**
+ * Available layout managers.
+ */
+package jexer.layout;
diff --git a/src/jexer/menu/TMenu.java b/src/jexer/menu/TMenu.java
new file mode 100644 (file)
index 0000000..6a875c7
--- /dev/null
@@ -0,0 +1,857 @@
+/*
+ * 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.menu;
+
+import java.util.ResourceBundle;
+
+import jexer.TApplication;
+import jexer.TKeypress;
+import jexer.TWidget;
+import jexer.TWindow;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.MnemonicString;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TMenu is a top-level collection of TMenuItems.
+ */
+public class TMenu extends TWindow {
+
+    /**
+     * Translated strings.
+     */
+    private static final ResourceBundle i18n = ResourceBundle.getBundle(TMenu.class.getName());
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // Reserved menu item IDs
+    public static final int MID_UNUSED          = -1;
+
+    // Tools menu
+    public static final int MID_REPAINT         = 1;
+    public static final int MID_VIEW_IMAGE      = 2;
+    public static final int MID_SCREEN_OPTIONS  = 3;
+
+    // File menu
+    public static final int MID_NEW             = 10;
+    public static final int MID_EXIT            = 11;
+    public static final int MID_QUIT            = MID_EXIT;
+    public static final int MID_OPEN_FILE       = 12;
+    public static final int MID_SHELL           = 13;
+
+    // Edit menu
+    public static final int MID_UNDO            = 20;
+    public static final int MID_REDO            = 21;
+    public static final int MID_CUT             = 22;
+    public static final int MID_COPY            = 23;
+    public static final int MID_PASTE           = 24;
+    public static final int MID_CLEAR           = 25;
+
+    // Search menu
+    public static final int MID_FIND            = 30;
+    public static final int MID_REPLACE         = 31;
+    public static final int MID_SEARCH_AGAIN    = 32;
+    public static final int MID_GOTO_LINE       = 33;
+
+    // Window menu
+    public static final int MID_TILE            = 40;
+    public static final int MID_CASCADE         = 41;
+    public static final int MID_CLOSE_ALL       = 42;
+    public static final int MID_WINDOW_MOVE     = 43;
+    public static final int MID_WINDOW_ZOOM     = 44;
+    public static final int MID_WINDOW_NEXT     = 45;
+    public static final int MID_WINDOW_PREVIOUS = 46;
+    public static final int MID_WINDOW_CLOSE    = 47;
+
+    // Help menu
+    public static final int MID_HELP_CONTENTS           = 50;
+    public static final int MID_HELP_INDEX              = 51;
+    public static final int MID_HELP_SEARCH             = 52;
+    public static final int MID_HELP_PREVIOUS           = 53;
+    public static final int MID_HELP_HELP               = 54;
+    public static final int MID_HELP_ACTIVE_FILE        = 55;
+    public static final int MID_ABOUT                   = 56;
+
+    // Table menu
+    public static final int MID_TABLE_RENAME_ROW                = 60;
+    public static final int MID_TABLE_RENAME_COLUMN             = 61;
+    public static final int MID_TABLE_VIEW_ROW_LABELS           = 70;
+    public static final int MID_TABLE_VIEW_COLUMN_LABELS        = 71;
+    public static final int MID_TABLE_VIEW_HIGHLIGHT_ROW        = 72;
+    public static final int MID_TABLE_VIEW_HIGHLIGHT_COLUMN     = 73;
+    public static final int MID_TABLE_BORDER_NONE               = 80;
+    public static final int MID_TABLE_BORDER_ALL                = 81;
+    public static final int MID_TABLE_BORDER_CELL_NONE          = 82;
+    public static final int MID_TABLE_BORDER_CELL_ALL           = 83;
+    public static final int MID_TABLE_BORDER_RIGHT              = 84;
+    public static final int MID_TABLE_BORDER_LEFT               = 85;
+    public static final int MID_TABLE_BORDER_TOP                = 86;
+    public static final int MID_TABLE_BORDER_BOTTOM             = 87;
+    public static final int MID_TABLE_BORDER_DOUBLE_BOTTOM      = 88;
+    public static final int MID_TABLE_BORDER_THICK_BOTTOM       = 89;
+    public static final int MID_TABLE_DELETE_LEFT               = 100;
+    public static final int MID_TABLE_DELETE_UP                 = 101;
+    public static final int MID_TABLE_DELETE_ROW                = 102;
+    public static final int MID_TABLE_DELETE_COLUMN             = 103;
+    public static final int MID_TABLE_INSERT_LEFT               = 104;
+    public static final int MID_TABLE_INSERT_RIGHT              = 105;
+    public static final int MID_TABLE_INSERT_ABOVE              = 106;
+    public static final int MID_TABLE_INSERT_BELOW              = 107;
+    public static final int MID_TABLE_COLUMN_NARROW             = 110;
+    public static final int MID_TABLE_COLUMN_WIDEN              = 111;
+    public static final int MID_TABLE_FILE_OPEN_CSV             = 115;
+    public static final int MID_TABLE_FILE_SAVE_CSV             = 116;
+    public static final int MID_TABLE_FILE_SAVE_TEXT            = 117;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, this is a sub-menu.  Note package private access.
+     */
+    boolean isSubMenu = false;
+
+    /**
+     * The X position of the menu's title.
+     */
+    private int titleX;
+
+    /**
+     * The shortcut and title.
+     */
+    private MnemonicString mnemonic;
+
+    /**
+     * If true, draw icons with menu items.  Note package private access.
+     */
+    boolean useIcons = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent application
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label mnemonic menu title.  Label must contain a keyboard
+     * shortcut (mnemonic), denoted by prefixing a letter with "&amp;",
+     * e.g. "&amp;File"
+     */
+    public TMenu(final TApplication parent, final int x, final int y,
+        final String label) {
+
+        super(parent, label, x, y, parent.getScreen().getWidth(),
+            parent.getScreen().getHeight());
+
+        // Setup the menu shortcut
+        mnemonic = new MnemonicString(label);
+        setTitle(mnemonic.getRawLabel());
+        assert (mnemonic.getShortcutIdx() >= 0);
+
+        // Recompute width and height to reflect an empty menu
+        setWidth(StringUtils.width(getTitle()) + 4);
+        setHeight(2);
+
+        setActive(false);
+
+        if (System.getProperty("jexer.menuIcons", "false").equals("true")) {
+            useIcons = true;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse button presses.
+     *
+     * @param mouse mouse button event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        this.mouse = mouse;
+        super.onMouseDown(mouse);
+
+        // Pass to children
+        for (TWidget widget: getChildren()) {
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.handleEvent(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        // Pass to children
+        for (TWidget widget: getChildren()) {
+            if (widget.mouseWouldHit(mouse)) {
+                // Dispatch to this child, also activate it
+                activate(widget);
+
+                // Set x and y relative to the child's coordinates
+                mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                widget.handleEvent(mouse);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Handle mouse movements.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        this.mouse = mouse;
+
+        // See if we should activate a different menu item
+        for (TWidget widget: getChildren()) {
+            if ((mouse.isMouse1())
+                && (widget.mouseWouldHit(mouse))
+            ) {
+                // Activate this menu item
+                activate(widget);
+                if (widget instanceof TSubMenu) {
+                    ((TSubMenu) widget).dispatch();
+                }
+                return;
+            }
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+
+        /*
+        System.err.printf("keypress: %s active child: %s\n", keypress,
+            getActiveChild());
+        */
+
+        if (getActiveChild() != this) {
+            if (getActiveChild() instanceof TMenu) {
+                getActiveChild().onKeypress(keypress);
+                return;
+            }
+
+            if (getActiveChild() instanceof TSubMenu) {
+                TSubMenu subMenu = (TSubMenu) getActiveChild();
+                if (subMenu.menu.isActive()) {
+                    subMenu.onKeypress(keypress);
+                    return;
+                }
+            }
+        }
+
+        if (keypress.equals(kbEsc)) {
+            getApplication().closeMenu();
+            return;
+        }
+        if (keypress.equals(kbDown)) {
+            switchWidget(true);
+            return;
+        }
+        if (keypress.equals(kbUp)) {
+            switchWidget(false);
+            return;
+        }
+        if (keypress.equals(kbRight)) {
+            getApplication().switchMenu(true);
+            return;
+        }
+        if (keypress.equals(kbLeft)) {
+            if (isSubMenu) {
+                getApplication().closeSubMenu();
+            } else {
+                getApplication().switchMenu(false);
+            }
+            return;
+        }
+
+        // Switch to a menuItem if it has an mnemonic
+        if (!keypress.getKey().isFnKey()
+            && !keypress.getKey().isAlt()
+            && !keypress.getKey().isCtrl()) {
+
+            // System.err.println("Checking children for mnemonic...");
+
+            for (TWidget widget: getChildren()) {
+                TMenuItem item = (TMenuItem) widget;
+                if ((item.isEnabled() == true)
+                    && (item.getMnemonic() != null)
+                    && (Character.toLowerCase(item.getMnemonic().getShortcut())
+                        == Character.toLowerCase(keypress.getKey().getChar()))
+                ) {
+                    // System.err.println("activate: " + item);
+
+                    // Send an enter keystroke to it
+                    activate(item);
+                    item.handleEvent(new TKeypressEvent(kbEnter));
+                    return;
+                }
+            }
+        }
+
+        // Dispatch the keypress to an active widget
+        for (TWidget widget: getChildren()) {
+            if (widget.isActive()) {
+                widget.handleEvent(keypress);
+                return;
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWindow ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw a top-level menu with title and menu items.
+     */
+    @Override
+    public void draw() {
+        CellAttributes background = getTheme().getColor("tmenu");
+
+        assert (isAbsoluteActive());
+
+        // Fill in the interior background
+        for (int i = 0; i < getHeight(); i++) {
+            hLineXY(0, i, getWidth(), ' ', background);
+        }
+
+        // Draw the box
+        char cTopLeft;
+        char cTopRight;
+        char cBottomLeft;
+        char cBottomRight;
+        char cHSide;
+
+        cTopLeft = GraphicsChars.ULCORNER;
+        cTopRight = GraphicsChars.URCORNER;
+        cBottomLeft = GraphicsChars.LLCORNER;
+        cBottomRight = GraphicsChars.LRCORNER;
+        cHSide = GraphicsChars.SINGLE_BAR;
+
+        // Place the corner characters
+        putCharXY(1, 0, cTopLeft, background);
+        putCharXY(getWidth() - 2, 0, cTopRight, background);
+        putCharXY(1, getHeight() - 1, cBottomLeft, background);
+        putCharXY(getWidth() - 2, getHeight() - 1, cBottomRight, background);
+
+        // Draw the box lines
+        hLineXY(1 + 1, 0, getWidth() - 4, cHSide, background);
+        hLineXY(1 + 1, getHeight() - 1, getWidth() - 4, cHSide, background);
+
+        // Draw a shadow
+        drawBoxShadow(0, 0, getWidth(), getHeight());
+    }
+
+    // ------------------------------------------------------------------------
+    // TMenu ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the menu title X position.
+     *
+     * @param titleX the position
+     */
+    public void setTitleX(final int titleX) {
+        this.titleX = titleX;
+    }
+
+    /**
+     * Get the menu title X position.
+     *
+     * @return the position
+     */
+    public int getTitleX() {
+        return titleX;
+    }
+
+    /**
+     * Get the mnemonic string.
+     *
+     * @return the full mnemonic string
+     */
+    public MnemonicString getMnemonic() {
+        return mnemonic;
+    }
+
+    /**
+     * Convenience function to add a menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label) {
+        return addItemInternal(id, label, null);
+    }
+
+    /**
+     * Convenience function to add a menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param enabled default state for enabled
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label,
+        final boolean enabled) {
+
+        assert (id >= 1024);
+        return addItemInternal(id, label, null, enabled, -1);
+    }
+
+    /**
+     * Convenience function to add a custom menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param key global keyboard accelerator
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label,
+        final TKeypress key) {
+
+        assert (id >= 1024);
+        return addItemInternal(id, label, key);
+    }
+
+    /**
+     * Convenience function to add a custom menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param key global keyboard accelerator
+     * @param enabled default state for enabled
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label,
+        final TKeypress key, final boolean enabled) {
+
+        TMenuItem item = addItem(id, label, key);
+        item.setEnabled(enabled);
+        return item;
+    }
+
+    /**
+     * Convenience function to add a custom menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param key global keyboard accelerator
+     * @return the new menu item
+     */
+    private TMenuItem addItemInternal(final int id, final String label,
+        final TKeypress key) {
+
+        return addItemInternal(id, label, key, true, -1);
+    }
+
+    /**
+     * Convenience function to add a custom menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param key global keyboard accelerator
+     * @param enabled default state for enabled
+     * @param icon icon picture/emoji
+     * @return the new menu item
+     */
+    private TMenuItem addItemInternal(final int id, final String label,
+        final TKeypress key, final boolean enabled, final int icon) {
+
+        int newY = getChildren().size() + 1;
+        assert (newY < getHeight());
+
+        TMenuItem menuItem = new TMenuItem(this, id, 1, newY, label, icon);
+        menuItem.setKey(key);
+        menuItem.setEnabled(enabled);
+        setHeight(getHeight() + 1);
+        if (menuItem.getWidth() + 2 > getWidth()) {
+            setWidth(menuItem.getWidth() + 2);
+        }
+        for (TWidget widget: getChildren()) {
+            widget.setWidth(getWidth() - 2);
+        }
+        getApplication().addMenuItem(menuItem);
+        getApplication().recomputeMenuX();
+        activate(0);
+        return menuItem;
+    }
+
+    /**
+     * Convenience function to add one of the default menu items.
+     *
+     * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+     * (inclusive).
+     * @return the new menu item
+     */
+    public TMenuItem addDefaultItem(final int id) {
+        return addDefaultItem(id, true);
+    }
+
+    /**
+     * Convenience function to add one of the default menu items.
+     *
+     * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+     * (inclusive).
+     * @param enabled default state for enabled
+     * @return the new menu item
+     */
+    public TMenuItem addDefaultItem(final int id, final boolean enabled) {
+        assert (id >= 0);
+        assert (id < 1024);
+
+        String label;
+        TKeypress key = null;
+        int icon = -1;
+        boolean checkable = false;
+        boolean checked = false;
+
+        switch (id) {
+
+        case MID_REPAINT:
+            label = i18n.getString("menuRepaintDesktop");
+            icon = 0x1F3A8;
+            break;
+
+        case MID_VIEW_IMAGE:
+            label = i18n.getString("menuViewImage");
+            break;
+
+        case MID_SCREEN_OPTIONS:
+            label = i18n.getString("menuScreenOptions");
+            break;
+
+        case MID_NEW:
+            label = i18n.getString("menuNew");
+            icon = 0x1F5CE;
+            break;
+
+        case MID_EXIT:
+            label = i18n.getString("menuExit");
+            key = kbAltX;
+            icon = 0x1F5D9;
+            break;
+
+        case MID_SHELL:
+            label = i18n.getString("menuShell");
+            icon = 0x1F5AE;
+            break;
+
+        case MID_OPEN_FILE:
+            label = i18n.getString("menuOpen");
+            key = kbF3;
+            icon = 0x1F5C1;
+            break;
+
+        case MID_UNDO:
+            label = i18n.getString("menuUndo");
+            key = kbCtrlZ;
+            break;
+        case MID_REDO:
+            label = i18n.getString("menuRedo");
+            key = kbCtrlY;
+            break;
+        case MID_CUT:
+            label = i18n.getString("menuCut");
+            key = kbCtrlX;
+            icon = 0x1F5F6;
+            break;
+        case MID_COPY:
+            label = i18n.getString("menuCopy");
+            key = kbCtrlC;
+            icon = 0x1F5D0;
+            break;
+        case MID_PASTE:
+            label = i18n.getString("menuPaste");
+            key = kbCtrlV;
+            icon = 0x1F4CB;
+            break;
+        case MID_CLEAR:
+            label = i18n.getString("menuClear");
+            break;
+
+        case MID_FIND:
+            label = i18n.getString("menuFind");
+            icon = 0x1F50D;
+            break;
+        case MID_REPLACE:
+            label = i18n.getString("menuReplace");
+            break;
+        case MID_SEARCH_AGAIN:
+            label = i18n.getString("menuSearchAgain");
+            key = kbCtrlL;
+            break;
+        case MID_GOTO_LINE:
+            label = i18n.getString("menuGotoLine");
+            break;
+
+        case MID_TILE:
+            label = i18n.getString("menuWindowTile");
+            break;
+        case MID_CASCADE:
+            label = i18n.getString("menuWindowCascade");
+            icon = 0x1F5D7;
+            break;
+        case MID_CLOSE_ALL:
+            label = i18n.getString("menuWindowCloseAll");
+            break;
+        case MID_WINDOW_MOVE:
+            label = i18n.getString("menuWindowMove");
+            key = kbCtrlF5;
+            icon = 0x263C;
+            break;
+        case MID_WINDOW_ZOOM:
+            label = i18n.getString("menuWindowZoom");
+            key = kbF5;
+            icon = 0x2195;
+            break;
+        case MID_WINDOW_NEXT:
+            label = i18n.getString("menuWindowNext");
+            key = kbF6;
+            icon = 0x2192;
+            break;
+        case MID_WINDOW_PREVIOUS:
+            label = i18n.getString("menuWindowPrevious");
+            key = kbShiftF6;
+            icon = 0x2190;
+            break;
+        case MID_WINDOW_CLOSE:
+            label = i18n.getString("menuWindowClose");
+            key = kbCtrlW;
+            break;
+
+        case MID_HELP_CONTENTS:
+            label = i18n.getString("menuHelpContents");
+            break;
+        case MID_HELP_INDEX:
+            label = i18n.getString("menuHelpIndex");
+            key = kbShiftF1;
+            break;
+        case MID_HELP_SEARCH:
+            label = i18n.getString("menuHelpSearch");
+            key = kbCtrlF1;
+            break;
+        case MID_HELP_PREVIOUS:
+            label = i18n.getString("menuHelpPrevious");
+            key = kbAltF1;
+            break;
+        case MID_HELP_HELP:
+            label = i18n.getString("menuHelpHelp");
+            break;
+        case MID_HELP_ACTIVE_FILE:
+            label = i18n.getString("menuHelpActive");
+            break;
+        case MID_ABOUT:
+            label = i18n.getString("menuHelpAbout");
+            break;
+
+        case MID_TABLE_RENAME_COLUMN:
+            label = i18n.getString("menuTableRenameColumn");
+            break;
+        case MID_TABLE_RENAME_ROW:
+            label = i18n.getString("menuTableRenameRow");
+            break;
+        case MID_TABLE_VIEW_ROW_LABELS:
+            label = i18n.getString("menuTableViewRowLabels");
+            checkable = true;
+            checked = true;
+            break;
+        case MID_TABLE_VIEW_COLUMN_LABELS:
+            label = i18n.getString("menuTableViewColumnLabels");
+            checkable = true;
+            checked = true;
+            break;
+        case MID_TABLE_VIEW_HIGHLIGHT_ROW:
+            label = i18n.getString("menuTableViewHighlightRow");
+            checkable = true;
+            checked = true;
+            break;
+        case MID_TABLE_VIEW_HIGHLIGHT_COLUMN:
+            label = i18n.getString("menuTableViewHighlightColumn");
+            checkable = true;
+            checked = true;
+            break;
+
+        case MID_TABLE_BORDER_NONE:
+            label = i18n.getString("menuTableBorderNone");
+            break;
+        case MID_TABLE_BORDER_ALL:
+            label = i18n.getString("menuTableBorderAll");
+            break;
+        case MID_TABLE_BORDER_CELL_NONE:
+            label = i18n.getString("menuTableBorderCellNone");
+            break;
+        case MID_TABLE_BORDER_CELL_ALL:
+            label = i18n.getString("menuTableBorderCellAll");
+            break;
+        case MID_TABLE_BORDER_RIGHT:
+            label = i18n.getString("menuTableBorderRight");
+            break;
+        case MID_TABLE_BORDER_LEFT:
+            label = i18n.getString("menuTableBorderLeft");
+            break;
+        case MID_TABLE_BORDER_TOP:
+            label = i18n.getString("menuTableBorderTop");
+            break;
+        case MID_TABLE_BORDER_BOTTOM:
+            label = i18n.getString("menuTableBorderBottom");
+            break;
+        case MID_TABLE_BORDER_DOUBLE_BOTTOM:
+            label = i18n.getString("menuTableBorderDoubleBottom");
+            break;
+        case MID_TABLE_BORDER_THICK_BOTTOM:
+            label = i18n.getString("menuTableBorderThickBottom");
+            break;
+        case MID_TABLE_DELETE_LEFT:
+            label = i18n.getString("menuTableDeleteLeft");
+            break;
+        case MID_TABLE_DELETE_UP:
+            label = i18n.getString("menuTableDeleteUp");
+            break;
+        case MID_TABLE_DELETE_ROW:
+            label = i18n.getString("menuTableDeleteRow");
+            break;
+        case MID_TABLE_DELETE_COLUMN:
+            label = i18n.getString("menuTableDeleteColumn");
+            break;
+        case MID_TABLE_INSERT_LEFT:
+            label = i18n.getString("menuTableInsertLeft");
+            break;
+        case MID_TABLE_INSERT_RIGHT:
+            label = i18n.getString("menuTableInsertRight");
+            break;
+        case MID_TABLE_INSERT_ABOVE:
+            label = i18n.getString("menuTableInsertAbove");
+            break;
+        case MID_TABLE_INSERT_BELOW:
+            label = i18n.getString("menuTableInsertBelow");
+            break;
+        case MID_TABLE_COLUMN_NARROW:
+            label = i18n.getString("menuTableColumnNarrow");
+            key = kbShiftLeft;
+            break;
+        case MID_TABLE_COLUMN_WIDEN:
+            label = i18n.getString("menuTableColumnWiden");
+            key = kbShiftRight;
+            break;
+        case MID_TABLE_FILE_OPEN_CSV:
+            label = i18n.getString("menuTableFileOpenCsv");
+            break;
+        case MID_TABLE_FILE_SAVE_CSV:
+            label = i18n.getString("menuTableFileSaveCsv");
+            break;
+        case MID_TABLE_FILE_SAVE_TEXT:
+            label = i18n.getString("menuTableFileSaveText");
+            break;
+
+        default:
+            throw new IllegalArgumentException("Invalid menu ID: " + id);
+        }
+
+        TMenuItem item = addItemInternal(id, label, key, enabled, icon);
+        item.setCheckable(checkable);
+        return item;
+    }
+
+    /**
+     * Convenience function to add a menu separator.
+     */
+    public void addSeparator() {
+        int newY = getChildren().size() + 1;
+        assert (newY < getHeight());
+
+        // We just have to construct it, don't need to hang onto what it
+        // makes.
+        new TMenuSeparator(this, 1, newY);
+        setHeight(getHeight() + 1);
+    }
+
+    /**
+     * Convenience function to add a sub-menu.
+     *
+     * @param title menu title.  Title must contain a keyboard shortcut,
+     * denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+     * @return the new sub-menu
+     */
+    public TSubMenu addSubMenu(final String title) {
+        int newY = getChildren().size() + 1;
+        assert (newY < getHeight());
+
+        TSubMenu subMenu = new TSubMenu(this, title, 1, newY);
+        setHeight(getHeight() + 1);
+        if (subMenu.getWidth() + 2 > getWidth()) {
+            setWidth(subMenu.getWidth() + 2);
+        }
+        for (TWidget widget: getChildren()) {
+            widget.setWidth(getWidth() - 2);
+        }
+        getApplication().recomputeMenuX();
+        activate(0);
+        subMenu.menu.setX(getX() + getWidth() - 2);
+
+        return subMenu;
+    }
+
+}
diff --git a/src/jexer/menu/TMenu.properties b/src/jexer/menu/TMenu.properties
new file mode 100644 (file)
index 0000000..692293e
--- /dev/null
@@ -0,0 +1,64 @@
+menuNew=&New
+menuExit=E&xit
+menuShell=O&S Shell
+menuOpen=&Open
+menuUndo=&Undo
+menuRedo=&Redo
+menuCut=Cu&t
+menuCopy=&Copy
+menuPaste=&Paste
+menuClear=C&lear
+menuFind=&Find...
+menuReplace=&Replace...
+menuSearchAgain=&Search again
+menuGotoLine=&Go to line number...
+menuWindowTile=&Tile
+menuWindowCascade=C&ascade
+menuWindowCloseAll=Cl&ose All
+menuWindowMove=&Size/Move
+menuWindowZoom=&Zoom
+menuWindowNext=&Next
+menuWindowPrevious=&Previous
+menuWindowClose=&Close
+menuHelpContents=&Contents
+menuHelpIndex=&Index
+menuHelpSearch=&Topic search
+menuHelpPrevious=&Previous topic
+menuHelpHelp=&Help on help
+menuHelpActive=Active &file...
+menuHelpAbout=&About...
+
+menuTableRenameRow=Rename &Row
+menuTableRenameColumn=Rename C&olumn
+menuTableViewRowLabels=&Row Labels
+menuTableViewColumnLabels=&Column Labels
+menuTableViewHighlightRow=Highlight Selected R&ow
+menuTableViewHighlightColumn=Highlight Selected Co&lumn
+menuTableBorderNone=N&one (Entire Table)
+menuTableBorderAll=&All (Entire Table)
+menuTableBorderCellNone=&None (Selected Cell)
+menuTableBorderCellAll=All (&Selected Cell)
+menuTableBorderRight=&Right
+menuTableBorderLeft=&Left
+menuTableBorderTop=&Top
+menuTableBorderBottom=&Bottom
+menuTableBorderDoubleBottom=Bottom (&Double)
+menuTableBorderThickBottom=Bottom (T&hick)
+menuTableDeleteLeft=Cell (Shift &Left)
+menuTableDeleteUp=Cell (Shift &Up)
+menuTableDeleteRow=Entire &Row
+menuTableDeleteColumn=Entire &Column
+menuTableInsertLeft=Column &Left
+menuTableInsertRight=Column &Right
+menuTableInsertAbove=Row &Above
+menuTableInsertBelow=Row &Below
+menuTableColumnNarrow=&Narrow
+menuTableColumnWiden=&Widen
+menuTableFileOpenCsv=Open &CSV...
+menuTableFileSaveCsv=Save As C&SV...
+menuTableFileSaveText=Save As &Text...
+
+menuRepaintDesktop=&Repaint desktop
+menuViewImage=&Open image...
+menuScreenOptions=&Screen options...
+
diff --git a/src/jexer/menu/TMenuItem.java b/src/jexer/menu/TMenuItem.java
new file mode 100644 (file)
index 0000000..b478059
--- /dev/null
@@ -0,0 +1,392 @@
+/*
+ * 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.menu;
+
+import jexer.TKeypress;
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.MnemonicString;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TMenuEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TMenuItem implements a menu item.
+ */
+public class TMenuItem extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Label for this menu item.
+     */
+    private String label;
+
+    /**
+     * Menu ID.  IDs less than 1024 are reserved for common system
+     * functions.  Existing ones are defined in TMenu, i.e. TMenu.MID_EXIT.
+     */
+    private int id = TMenu.MID_UNUSED;
+
+    /**
+     * When true, this item can be checked or unchecked.
+     */
+    private boolean checkable = false;
+
+    /**
+     * When true, this item is checked.
+     */
+    private boolean checked = false;
+
+    /**
+     * Global shortcut key.
+     */
+    private TKeypress key;
+
+    /**
+     * The title string.  Use '&' to specify a mnemonic, i.e. "&File" will
+     * highlight the 'F' and allow 'f' or 'F' to select it.
+     */
+    private MnemonicString mnemonic;
+
+    /**
+     * An optional 2-cell-wide picture/icon for this item.
+     */
+    private int icon = -1;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param parent parent widget
+     * @param id menu id
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label menu item title
+     */
+    TMenuItem(final TMenu parent, final int id, final int x, final int y,
+        final String label) {
+
+        this(parent, id, x, y, label, -1);
+    }
+
+    /**
+     * Package private constructor.
+     *
+     * @param parent parent widget
+     * @param id menu id
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param label menu item title
+     * @param icon icon picture/emoji
+     */
+    TMenuItem(final TMenu parent, final int id, final int x, final int y,
+        final String label, final int icon) {
+
+        // Set parent and window
+        super(parent);
+
+        mnemonic = new MnemonicString(label);
+
+        setX(x);
+        setY(y);
+        setHeight(1);
+        this.label = mnemonic.getRawLabel();
+        if (parent.useIcons) {
+            setWidth(StringUtils.width(label) + 6);
+        } else {
+            setWidth(StringUtils.width(label) + 4);
+        }
+        this.id = id;
+        this.icon = icon;
+
+        // Default state for some known menu items
+        switch (id) {
+
+        case TMenu.MID_CUT:
+            setEnabled(false);
+            break;
+        case TMenu.MID_COPY:
+            setEnabled(false);
+            break;
+        case TMenu.MID_PASTE:
+            setEnabled(false);
+            break;
+        case TMenu.MID_CLEAR:
+            setEnabled(false);
+            break;
+
+        case TMenu.MID_TILE:
+            break;
+        case TMenu.MID_CASCADE:
+            break;
+        case TMenu.MID_CLOSE_ALL:
+            break;
+        case TMenu.MID_WINDOW_MOVE:
+            break;
+        case TMenu.MID_WINDOW_ZOOM:
+            break;
+        case TMenu.MID_WINDOW_NEXT:
+            break;
+        case TMenu.MID_WINDOW_PREVIOUS:
+            break;
+        case TMenu.MID_WINDOW_CLOSE:
+            break;
+        default:
+            break;
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns true if the mouse is currently on the menu item.
+     *
+     * @param mouse mouse event
+     * @return if true then the mouse is currently on this item
+     */
+    private boolean mouseOnMenuItem(final TMouseEvent mouse) {
+        if ((mouse.getY() == 0)
+            && (mouse.getX() >= 0)
+            && (mouse.getX() < getWidth())
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handle mouse button releases.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        if ((mouseOnMenuItem(mouse)) && (mouse.isMouse1())) {
+            dispatch();
+            return;
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbEnter)) {
+            dispatch();
+            return;
+        }
+
+        // Pass to parent for the things we don't care about.
+        super.onKeypress(keypress);
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw a menu item with label.
+     */
+    @Override
+    public void draw() {
+        CellAttributes background = getTheme().getColor("tmenu");
+        CellAttributes menuColor;
+        CellAttributes menuMnemonicColor;
+        if (isAbsoluteActive()) {
+            menuColor = getTheme().getColor("tmenu.highlighted");
+            menuMnemonicColor = getTheme().getColor("tmenu.mnemonic.highlighted");
+        } else {
+            if (isEnabled()) {
+                menuColor = getTheme().getColor("tmenu");
+                menuMnemonicColor = getTheme().getColor("tmenu.mnemonic");
+            } else {
+                menuColor = getTheme().getColor("tmenu.disabled");
+                menuMnemonicColor = getTheme().getColor("tmenu.disabled");
+            }
+        }
+
+        boolean useIcons = ((TMenu) getParent()).useIcons;
+
+        char cVSide = GraphicsChars.WINDOW_SIDE;
+        vLineXY(0, 0, 1, cVSide, background);
+        vLineXY(getWidth() - 1, 0, 1, cVSide, background);
+
+        hLineXY(1, 0, getWidth() - 2, ' ', menuColor);
+        putStringXY(2 + (useIcons ? 2 : 0), 0, mnemonic.getRawLabel(),
+            menuColor);
+        if (key != null) {
+            String keyLabel = key.toString();
+            putStringXY((getWidth() - StringUtils.width(keyLabel) - 2), 0,
+                keyLabel, menuColor);
+        }
+        if (mnemonic.getScreenShortcutIdx() >= 0) {
+            putCharXY(2 + (useIcons ? 2 : 0) + mnemonic.getScreenShortcutIdx(),
+                0, mnemonic.getShortcut(), menuMnemonicColor);
+        }
+        if (checked) {
+            assert (checkable);
+            putCharXY(1, 0, GraphicsChars.CHECK, menuColor);
+        }
+        if ((useIcons == true) && (icon != -1)) {
+            putCharXY(2, 0, icon, menuColor);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TMenuItem --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the menu item ID.
+     *
+     * @return the id
+     */
+    public final int getId() {
+        return id;
+    }
+
+    /**
+     * Set checkable flag.
+     *
+     * @param checkable if true, this menu item can be checked/unchecked
+     */
+    public final void setCheckable(final boolean checkable) {
+        this.checkable = checkable;
+    }
+
+    /**
+     * Get checkable flag.
+     *
+     * @return true if this menu item is both checkable and checked
+     */
+    public final boolean getChecked() {
+        return ((checkable == true) && (checked == true));
+    }
+
+    /**
+     * Set checked flag.  Note that setting checked on an item checkable will
+     * do nothing.
+     *
+     * @param checked if true, and if this menu item is checkable, then
+     * getChecked() will return true
+     */
+    public final void setChecked(final boolean checked) {
+        if (checkable) {
+            this.checked = checked;
+        } else {
+            this.checked = false;
+        }
+    }
+
+    /**
+     * Get the mnemonic string for this menu item.
+     *
+     * @return mnemonic string
+     */
+    public final MnemonicString getMnemonic() {
+        return mnemonic;
+    }
+
+    /**
+     * Get a global accelerator key for this menu item.
+     *
+     * @return global keyboard accelerator, or null if no key is associated
+     * with this item
+     */
+    public final TKeypress getKey() {
+        return key;
+    }
+
+    /**
+     * Set a global accelerator key for this menu item.
+     *
+     * @param key global keyboard accelerator
+     */
+    public final void setKey(final TKeypress key) {
+        this.key = key;
+
+        if (key != null) {
+            int newWidth = (StringUtils.width(label) + 4 +
+                StringUtils.width(key.toString()) + 2);
+            if (((TMenu) getParent()).useIcons) {
+                newWidth += 2;
+            }
+            if (newWidth > getWidth()) {
+                setWidth(newWidth);
+            }
+        }
+    }
+
+    /**
+     * Get a picture/emoji icon for this menu item.
+     *
+     * @return the codepoint, or -1 if no icon is specified for this menu
+     * item
+     */
+    public final int getIcon() {
+        return icon;
+    }
+
+    /**
+     * Set a picture/emoji icon for this menu item.
+     *
+     * @param icon a codepoint, or -1 to unset the icon
+     */
+    public final void setIcon(final int icon) {
+        this.icon = icon;
+    }
+
+    /**
+     * Dispatch event(s) due to selection or click.
+     */
+    public void dispatch() {
+        assert (isEnabled());
+
+        getApplication().postMenuEvent(new TMenuEvent(id));
+        if (checkable) {
+            checked = !checked;
+        }
+    }
+
+}
diff --git a/src/jexer/menu/TMenuSeparator.java b/src/jexer/menu/TMenuSeparator.java
new file mode 100644 (file)
index 0000000..0528e5d
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * 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.menu;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+
+/**
+ * TMenuSeparator is a special case menu item.
+ */
+public class TMenuSeparator extends TMenuItem {
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     */
+    TMenuSeparator(final TMenu parent, final int x, final int y) {
+        super(parent, TMenu.MID_UNUSED, x, y, "");
+        setEnabled(false);
+        setActive(false);
+        setWidth(parent.getWidth() - 2);
+    }
+
+    // ------------------------------------------------------------------------
+    // TMenuItem --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw a menu separator.
+     */
+    @Override
+    public void draw() {
+        CellAttributes background = getTheme().getColor("tmenu");
+
+        putCharXY(0, 0, GraphicsChars.CP437[0xC3], background);
+        putCharXY(getWidth() - 1, 0, GraphicsChars.CP437[0xB4], background);
+        hLineXY(1, 0, getWidth() - 2, GraphicsChars.SINGLE_BAR, background);
+    }
+
+}
diff --git a/src/jexer/menu/TSubMenu.java b/src/jexer/menu/TSubMenu.java
new file mode 100644 (file)
index 0000000..be281b5
--- /dev/null
@@ -0,0 +1,296 @@
+/*
+ * 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.menu;
+
+import jexer.TKeypress;
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.event.TKeypressEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TSubMenu is a special case menu item that wraps another TMenu.
+ */
+public class TSubMenu extends TMenuItem {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The menu window.  Note package private access.
+     */
+    TMenu menu;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param parent parent widget
+     * @param title menu title.  Title must contain a keyboard shortcut,
+     * denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+     * @param x column relative to parent
+     * @param y row relative to parent
+     */
+    TSubMenu(final TMenu parent, final String title, final int x, final int y) {
+        super(parent, TMenu.MID_UNUSED, x, y, title);
+
+        setActive(false);
+        setEnabled(true);
+
+        this.menu = new TMenu(parent.getApplication(), x, getAbsoluteY() - 1,
+            title);
+        setWidth(menu.getWidth() + 2);
+
+        this.menu.isSubMenu = true;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+
+        // Open me if they hit my mnemonic.
+        if (!keypress.getKey().isFnKey()
+            && !keypress.getKey().isAlt()
+            && !keypress.getKey().isCtrl()
+            && (getMnemonic() != null)
+            && (Character.toLowerCase(getMnemonic().getShortcut())
+                == Character.toLowerCase(keypress.getKey().getChar()))
+        ) {
+            dispatch();
+            return;
+        }
+
+        if (menu.isActive()) {
+            menu.onKeypress(keypress);
+            return;
+        }
+
+        if (keypress.equals(kbEnter)) {
+            dispatch();
+            return;
+        }
+
+        if (keypress.equals(kbRight)) {
+            dispatch();
+            return;
+        }
+
+        if (keypress.equals(kbDown)) {
+            getParent().switchWidget(true);
+            return;
+        }
+
+        if (keypress.equals(kbUp)) {
+            getParent().switchWidget(false);
+            return;
+        }
+
+        if (keypress.equals(kbLeft)) {
+            TMenu parentMenu = (TMenu) getParent();
+            if (parentMenu.isSubMenu) {
+                getApplication().closeSubMenu();
+            } else {
+                getApplication().switchMenu(false);
+            }
+            return;
+        }
+
+        if (keypress.equals(kbEsc)) {
+            getApplication().closeMenu();
+            return;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TMenuItem --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw the menu title.
+     */
+    @Override
+    public void draw() {
+        super.draw();
+
+        CellAttributes menuColor;
+        if (isAbsoluteActive()) {
+            menuColor = getTheme().getColor("tmenu.highlighted");
+        } else {
+            if (isEnabled()) {
+                menuColor = getTheme().getColor("tmenu");
+            } else {
+                menuColor = getTheme().getColor("tmenu.disabled");
+            }
+        }
+
+        // Add the arrow
+        putCharXY(getWidth() - 2, 0, GraphicsChars.CP437[0x10], menuColor);
+    }
+
+    /**
+     * Override dispatch() to do nothing.
+     */
+    @Override
+    public void dispatch() {
+        assert (isEnabled());
+        if (isAbsoluteActive()) {
+            if (!menu.isActive()) {
+                getApplication().addSubMenu(menu);
+                menu.setActive(true);
+            }
+        }
+    }
+
+    /**
+     * Returns my active widget.
+     *
+     * @return widget that is active, or this if no children
+     */
+    @Override
+    public TWidget getActiveChild() {
+        if (menu.isActive()) {
+            return menu;
+        }
+        // Menu not active, return me
+        return this;
+    }
+
+    // ------------------------------------------------------------------------
+    // TSubMenu ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Convenience function to add a custom menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param key global keyboard accelerator
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label,
+        final TKeypress key) {
+
+        return menu.addItem(id, label, key);
+    }
+
+    /**
+     * Convenience function to add a custom menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param key global keyboard accelerator
+     * @param enabled default state for enabled
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label,
+        final TKeypress key, final boolean enabled) {
+
+        return menu.addItem(id, label, key, enabled);
+    }
+
+    /**
+     * Convenience function to add a menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label) {
+        return menu.addItem(id, label);
+    }
+
+    /**
+     * Convenience function to add a menu item.
+     *
+     * @param id menu item ID.  Must be greater than 1024.
+     * @param label menu item label
+     * @param enabled default state for enabled
+     * @return the new menu item
+     */
+    public TMenuItem addItem(final int id, final String label,
+        final boolean enabled) {
+
+        return menu.addItem(id, label, enabled);
+    }
+
+    /**
+     * Convenience function to add one of the default menu items.
+     *
+     * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+     * (inclusive).
+     * @return the new menu item
+     */
+    public TMenuItem addDefaultItem(final int id) {
+        return menu.addDefaultItem(id);
+    }
+
+    /**
+     * Convenience function to add one of the default menu items.
+     *
+     * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+     * (inclusive).
+     * @param enabled default state for enabled
+     * @return the new menu item
+     */
+    public TMenuItem addDefaultItem(final int id, final boolean enabled) {
+        return menu.addDefaultItem(id, enabled);
+    }
+
+    /**
+     * Convenience function to add a menu separator.
+     */
+    public void addSeparator() {
+        menu.addSeparator();
+    }
+
+    /**
+     * Convenience function to add a sub-menu.
+     *
+     * @param title menu title.  Title must contain a keyboard shortcut,
+     * denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+     * @return the new sub-menu
+     */
+    public TSubMenu addSubMenu(final String title) {
+        return menu.addSubMenu(title);
+    }
+
+}
diff --git a/src/jexer/menu/package-info.java b/src/jexer/menu/package-info.java
new file mode 100644 (file)
index 0000000..2c10393
--- /dev/null
@@ -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
+ */
+
+/**
+ * Menu bar support classes.
+ */
+package jexer.menu;
diff --git a/src/jexer/net/TelnetInputStream.java b/src/jexer/net/TelnetInputStream.java
new file mode 100644 (file)
index 0000000..be3ab50
--- /dev/null
@@ -0,0 +1,1399 @@
+/*
+ * 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.net;
+
+import java.io.InputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.TreeMap;
+
+import jexer.backend.SessionInfo;
+import static jexer.net.TelnetSocket.*;
+
+/**
+ * TelnetInputStream works with TelnetSocket to perform the telnet protocol.
+ */
+public class TelnetInputStream extends InputStream implements SessionInfo {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The root TelnetSocket that has my telnet protocol state.
+     */
+    private TelnetSocket master;
+
+    /**
+     * The raw socket's InputStream.
+     */
+    private InputStream input;
+
+    /**
+     * The telnet-aware OutputStream.
+     */
+    private TelnetOutputStream output;
+
+    /**
+     * Persistent read buffer.  In practice this will only be used if the
+     * single-byte read() is called sometime.
+     */
+    private byte [] readBuffer;
+
+    /**
+     * Current writing position in readBuffer - what is passed into
+     * input.read().
+     */
+    private int readBufferEnd;
+
+    /**
+     * Current read position in readBuffer - what is passed to the client in
+     * response to this.read().
+     */
+    private int readBufferStart;
+
+    /**
+     * User name.
+     */
+    private String username = "";
+
+    /**
+     * Language.
+     */
+    private String language = "en_US";
+
+    /**
+     * Text window width.
+     */
+    private int windowWidth = 80;
+
+    /**
+     * Text window height.
+     */
+    private int windowHeight = 24;
+
+    /**
+     * When true, the last read byte from the remote side was IAC.
+     */
+    private boolean iac = false;
+
+    /**
+     * When true, we are in the middle of a DO/DONT/WILL/WONT negotiation.
+     */
+    private boolean dowill = false;
+
+    /**
+     * The telnet option being negotiated.
+     */
+    private int dowillType = 0;
+
+    /**
+     * When true, we are waiting to see the end of the sub-negotiation
+     * sequence.
+     */
+    private boolean subnegEnd = false;
+
+    /**
+     * When true, the last byte read from the remote side was CR.
+     */
+    private boolean readCR = false;
+
+    /**
+     * The subnegotiation buffer.
+     */
+    private ArrayList<Byte> subnegBuffer;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param master the master TelnetSocket
+     * @param input the underlying socket's InputStream
+     * @param output the telnet-aware OutputStream
+     */
+    TelnetInputStream(final TelnetSocket master, final InputStream input,
+        final TelnetOutputStream output) {
+
+        this.master = master;
+        this.input  = input;
+        this.output = output;
+
+        // Setup new read buffer
+        readBuffer      = new byte[1024];
+        readBufferStart = 0;
+        readBufferEnd   = 0;
+        subnegBuffer    = new ArrayList<Byte>();
+    }
+
+    // ------------------------------------------------------------------------
+    // SessionInfo ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Username getter.
+     *
+     * @return the username
+     */
+    public String getUsername() {
+        return this.username;
+    }
+
+    /**
+     * Username setter.
+     *
+     * @param username the value
+     */
+    public void setUsername(final String username) {
+        this.username = username;
+    }
+
+    /**
+     * Language getter.
+     *
+     * @return the language
+     */
+    public String getLanguage() {
+        return this.language;
+    }
+
+    /**
+     * Language setter.
+     *
+     * @param language the value
+     */
+    public void setLanguage(final String language) {
+        this.language = language;
+    }
+
+    /**
+     * Get the terminal type as reported by the telnet Terminal Type option.
+     *
+     * @return the terminal type
+     */
+    public String getTerminalType() {
+        return master.terminalType;
+    }
+
+    /**
+     * Text window width getter.
+     *
+     * @return the window width
+     */
+    public int getWindowWidth() {
+        return windowWidth;
+    }
+
+    /**
+     * Text window height getter.
+     *
+     * @return the window height
+     */
+    public int getWindowHeight() {
+        return windowHeight;
+    }
+
+    /**
+     * Re-query the text window size.
+     */
+    public void queryWindowSize() {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // InputStream ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns an estimate of the number of bytes that can be read (or
+     * skipped over) from this input stream without blocking by the next
+     * invocation of a method for this input stream.
+     *
+     * @return an estimate of the number of bytes that can be read (or
+     * skipped over) from this input stream without blocking or 0 when it
+     * reaches the end of the input stream.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int available() throws IOException {
+        if (readBuffer == null) {
+            throw new IOException("InputStream is closed");
+        }
+        if (readBufferEnd - readBufferStart > 0) {
+            return (readBufferEnd - readBufferStart);
+        }
+        return input.available();
+    }
+
+    /**
+     * Closes this input stream and releases any system resources associated
+     * with the stream.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void close() throws IOException {
+        if (readBuffer != null) {
+            readBuffer = null;
+            input.close();
+        }
+    }
+
+    /**
+     * Marks the current position in this input stream.
+     *
+     * @param readLimit the maximum limit of bytes that can be read before
+     * the mark position becomes invalid
+     */
+    @Override
+    public void mark(final int readLimit) {
+        // Do nothing
+    }
+
+    /**
+     * Tests if this input stream supports the mark and reset methods.
+     *
+     * @return true if this stream instance supports the mark and reset
+     * methods; false otherwise
+     */
+    @Override
+    public boolean markSupported() {
+        return false;
+    }
+
+    /**
+     * Reads the next byte of data from the input stream.
+     *
+     * @return the next byte of data, or -1 if there is no more data because
+     * the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int read() throws IOException {
+
+        // If the post-processed buffer has bytes, use that.
+        if (readBufferEnd - readBufferStart > 0) {
+            readBufferStart++;
+            return readBuffer[readBufferStart - 1];
+        }
+
+        // The buffer is empty, so reset the indexes to 0.
+        readBufferStart = 0;
+        readBufferEnd   = 0;
+
+        // Read some fresh data and run it through the telnet protocol.
+        int rc = readImpl(readBuffer, readBufferEnd,
+            readBuffer.length - readBufferEnd);
+
+        // If we got something, return it.
+        if (rc > 0) {
+            readBufferEnd += rc;
+            readBufferStart++;
+            return readBuffer[readBufferStart - 1];
+        }
+        // If we read 0, I screwed up big time.
+        assert (rc != 0);
+
+        // We read -1 (EOF).
+        return rc;
+    }
+
+    /**
+     * Reads some number of bytes from the input stream and stores them into
+     * the buffer array b.
+     *
+     * @param b the buffer into which the data is read.
+     * @return the total number of bytes read into the buffer, or -1 if there
+     * is no more data because the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int read(final byte[] b) throws IOException {
+        return read(b, 0, b.length);
+    }
+
+    /**
+     * Reads up to len bytes of data from the input stream into an array of
+     * bytes.
+     *
+     * @param b the buffer into which the data is read.
+     * @param off the start offset in array b at which the data is written.
+     * @param len the maximum number of bytes to read.
+     * @return the total number of bytes read into the buffer, or -1 if there
+     * is no more data because the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public int read(final byte[] b, final int off,
+        final int len) throws IOException {
+
+        // The only time we can return 0 is if len is 0, as per the
+        // InputStream contract.
+        if (len == 0) {
+            return 0;
+        }
+
+        // If the post-processed buffer has bytes, use that.
+        if (readBufferEnd - readBufferStart > 0) {
+            int n = Math.min(len, readBufferEnd - readBufferStart);
+            System.arraycopy(b, off, readBuffer, readBufferStart, n);
+            readBufferStart += n;
+            return n;
+        }
+
+        // The buffer is empty, so reset the indexes to 0.
+        readBufferStart = 0;
+        readBufferEnd   = 0;
+
+        // The maximum number of bytes we will ask for will definitely be
+        // within the bounds of what we can return in a single call.
+        int n = Math.min(len, readBuffer.length);
+
+        // Read some fresh data and run it through the telnet protocol.
+        int rc = readImpl(readBuffer, readBufferEnd, n);
+
+        // If we got something, return it.
+        if (rc > 0) {
+            System.arraycopy(readBuffer, 0, b, off, rc);
+            return rc;
+        }
+        // If we read 0, I screwed up big time.
+        assert (rc != 0);
+
+        // We read -1 (EOF).
+        return rc;
+    }
+
+    /**
+     * Repositions this stream to the position at the time the mark method
+     * was last called on this input stream.  This is not supported by
+     * TelnetInputStream, so IOException is always thrown.
+     *
+     * @throws IOException if this function is used
+     */
+    @Override
+    public void reset() throws IOException {
+        throw new IOException("InputStream does not support mark/reset");
+    }
+
+    /**
+     * Skips over and discards n bytes of data from this input stream.
+     *
+     * @param n the number of bytes to be skipped
+     * @return the actual number of bytes skipped
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public long skip(final long n) throws IOException {
+        if (n < 0) {
+            return 0;
+        }
+        for (int i = 0; i < n; i++) {
+            read();
+        }
+        return n;
+    }
+
+    // ------------------------------------------------------------------------
+    // TelnetInputStream ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * For debugging, return a descriptive string for this telnet option.
+     * These are pulled from: http://www.iana.org/assignments/telnet-options
+     *
+     * @param option the telnet option byte
+     * @return a string describing the telnet option code
+     */
+    @SuppressWarnings("unused")
+    private String optionString(final int option) {
+        switch (option) {
+        case 0: return "Binary Transmission";
+        case 1: return "Echo";
+        case 2: return "Reconnection";
+        case 3: return "Suppress Go Ahead";
+        case 4: return "Approx Message Size Negotiation";
+        case 5: return "Status";
+        case 6: return "Timing Mark";
+        case 7: return "Remote Controlled Trans and Echo";
+        case 8: return "Output Line Width";
+        case 9: return "Output Page Size";
+        case 10: return "Output Carriage-Return Disposition";
+        case 11: return "Output Horizontal Tab Stops";
+        case 12: return "Output Horizontal Tab Disposition";
+        case 13: return "Output Formfeed Disposition";
+        case 14: return "Output Vertical Tabstops";
+        case 15: return "Output Vertical Tab Disposition";
+        case 16: return "Output Linefeed Disposition";
+        case 17: return "Extended ASCII";
+        case 18: return "Logout";
+        case 19: return "Byte Macro";
+        case 20: return "Data Entry Terminal";
+        case 21: return "SUPDUP";
+        case 22: return "SUPDUP Output";
+        case 23: return "Send Location";
+        case 24: return "Terminal Type";
+        case 25: return "End of Record";
+        case 26: return "TACACS User Identification";
+        case 27: return "Output Marking";
+        case 28: return "Terminal Location Number";
+        case 29: return "Telnet 3270 Regime";
+        case 30: return "X.3 PAD";
+        case 31: return "Negotiate About Window Size";
+        case 32: return "Terminal Speed";
+        case 33: return "Remote Flow Control";
+        case 34: return "Linemode";
+        case 35: return "X Display Location";
+        case 36: return "Environment Option";
+        case 37: return "Authentication Option";
+        case 38: return "Encryption Option";
+        case 39: return "New Environment Option";
+        case 40: return "TN3270E";
+        case 41: return "XAUTH";
+        case 42: return "CHARSET";
+        case 43: return "Telnet Remote Serial Port (RSP)";
+        case 44: return "Com Port Control Option";
+        case 45: return "Telnet Suppress Local Echo";
+        case 46: return "Telnet Start TLS";
+        case 47: return "KERMIT";
+        case 48: return "SEND-URL";
+        case 49: return "FORWARD_X";
+        case 138: return "TELOPT PRAGMA LOGON";
+        case 139: return "TELOPT SSPI LOGON";
+        case 140: return "TELOPT PRAGMA HEARTBEAT";
+        case 255: return "Extended-Options-List";
+        default:
+            if ((option >= 50) && (option <= 137)) {
+                return "Unassigned";
+            }
+            return "UNKNOWN - OTHER";
+        }
+    }
+
+    /**
+     * Send a DO/DON'T/WILL/WON'T response to the remote side.
+     *
+     * @param response a TELNET_DO/DONT/WILL/WONT byte
+     * @param option telnet option byte (binary mode, term type, etc.)
+     * @throws IOException if an I/O error occurs
+     */
+    private void respond(final int response,
+        final int option) throws IOException {
+
+        byte [] buffer = new byte[3];
+        buffer[0] = (byte) TELNET_IAC;
+        buffer[1] = (byte) response;
+        buffer[2] = (byte) option;
+
+        output.rawWrite(buffer);
+    }
+
+    /**
+     * Tell the remote side we WILL support an option.
+     *
+     * @param option telnet option byte (binary mode, term type, etc.)
+     * @throws IOException if an I/O error occurs
+     */
+    private void WILL(final int option) throws IOException {
+        respond(TELNET_WILL, option);
+    }
+
+    /**
+     * Tell the remote side we WON'T support an option.
+     *
+     * @param option telnet option byte (binary mode, term type, etc.)
+     * @throws IOException if an I/O error occurs
+     */
+    private void WONT(final int option) throws IOException {
+        respond(TELNET_WONT, option);
+    }
+
+    /**
+     * Tell the remote side we DO support an option.
+     *
+     * @param option telnet option byte (binary mode, term type, etc.)
+     * @throws IOException if an I/O error occurs
+     */
+    private void DO(final int option) throws IOException {
+        respond(TELNET_DO, option);
+    }
+
+    /**
+     * Tell the remote side we DON'T support an option.
+     *
+     * @param option telnet option byte (binary mode, term type, etc.)
+     * @throws IOException if an I/O error occurs
+     */
+    private void DONT(final int option) throws IOException {
+        respond(TELNET_DONT, option);
+    }
+
+    /**
+     * Tell the remote side we WON't or DON'T support an option.
+     *
+     * @param remoteQuery a TELNET_DO/DONT/WILL/WONT byte
+     * @param option telnet option byte (binary mode, term type, etc.)
+     * @throws IOException if an I/O error occurs
+     */
+    private void refuse(final int remoteQuery,
+        final int option) throws IOException {
+
+        if (remoteQuery == TELNET_DO) {
+            WONT(option);
+        } else {
+            DONT(option);
+        }
+    }
+
+    /**
+     * Build sub-negotiation packet (RFC 855).
+     *
+     * @param option telnet option
+     * @param response output buffer of response bytes
+     * @throws IOException if an I/O error occurs
+     */
+    private void telnetSendSubnegResponse(final int option,
+        final byte [] response) throws IOException {
+
+        byte [] buffer = new byte[response.length + 5];
+        buffer[0] = (byte) TELNET_IAC;
+        buffer[1] = (byte) TELNET_SB;
+        buffer[2] = (byte) option;
+        System.arraycopy(response, 0, buffer, 3, response.length);
+        buffer[response.length + 3] = (byte) TELNET_IAC;
+        buffer[response.length + 4] = (byte) TELNET_SE;
+        output.rawWrite(buffer);
+    }
+
+    /**
+     * Telnet option: Terminal Speed (RFC 1079).  Client side.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    private void telnetSendTerminalSpeed() throws IOException {
+        byte [] response = {0, '3', '8', '4', '0', '0', ',',
+                            '3', '8', '4', '0', '0'};
+        telnetSendSubnegResponse(32, response);
+    }
+
+    /**
+     * Telnet option: Terminal Type (RFC 1091).  Client side.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    private void telnetSendTerminalType() throws IOException {
+        byte [] response = {0, 'v', 't', '1', '0', '0' };
+        telnetSendSubnegResponse(24, response);
+    }
+
+    /**
+     * Telnet option: Terminal Type (RFC 1091).  Server side.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    private void requestTerminalType() throws IOException {
+        byte [] response = new byte[1];
+        response[0] = 1;
+        telnetSendSubnegResponse(24, response);
+    }
+
+    /**
+     * Telnet option: Terminal Speed (RFC 1079).  Server side.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    private void requestTerminalSpeed() throws IOException {
+        byte [] response = new byte[1];
+        response[0] = 1;
+        telnetSendSubnegResponse(32, response);
+    }
+
+    /**
+     * Telnet option: New Environment (RFC 1572).  Server side.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    private void requestEnvironment() throws IOException {
+        byte [] response = new byte[1];
+        response[0] = 1;
+        telnetSendSubnegResponse(39, response);
+    }
+
+    /**
+     * Send the options we want to negotiate on.
+     *
+     * <p>The options we use are:
+     *
+     * <p>
+     * <pre>
+     *     Binary Transmission           RFC 856
+     *     Suppress Go Ahead             RFC 858
+     *     Negotiate About Window Size   RFC 1073
+     *     Terminal Type                 RFC 1091
+     *     Terminal Speed                RFC 1079
+     *     New Environment               RFC 1572
+     *
+     * When run as a server:
+     *     Echo                          RFC 857
+     * </pre>
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    void telnetSendOptions() throws IOException {
+        if (master.binaryMode == false) {
+            // Binary Transmission: must ask both do and will
+            DO(0);
+            WILL(0);
+        }
+
+        if (master.goAhead == true) {
+            // Suppress Go Ahead
+            DO(3);
+            WILL(3);
+        }
+
+        // Server only options
+        if (master.isServer == true) {
+            // Enable Echo - I echo to them, they do not echo back to me.
+            DONT(1);
+            WILL(1);
+
+            if (master.doTermType == true) {
+                // Terminal type - request it
+                DO(24);
+            }
+
+            if (master.doTermSpeed == true) {
+                // Terminal speed - request it
+                DO(32);
+            }
+
+            if (master.doNAWS == true) {
+                // NAWS - request it
+                DO(31);
+            }
+
+            if (master.doEnvironment == true) {
+                // Environment - request it
+                DO(39);
+            }
+
+        } else {
+
+            if (master.doTermType == true) {
+                // Terminal type - request it
+                WILL(24);
+            }
+
+            if (master.doTermSpeed == true) {
+                // Terminal speed - request it
+                WILL(32);
+            }
+
+            if (master.doNAWS == true) {
+                // NAWS - request it
+                WILL(31);
+            }
+
+            if (master.doEnvironment == true) {
+                // Environment - request it
+                WILL(39);
+            }
+        }
+
+        // Push it all out
+        output.flush();
+    }
+
+    /**
+     * New Environment parsing state.
+     */
+    private enum EnvState {
+        INIT,
+        TYPE,
+        NAME,
+        VALUE
+    }
+
+    /**
+     * Handle the New Environment option.  Note that this implementation
+     * fails to handle ESC as defined in RFC 1572.
+     */
+    private void handleNewEnvironment() {
+        Map<String, String> newEnv = new TreeMap<String, String>();
+
+        EnvState state = EnvState.INIT;
+        StringBuilder name = new StringBuilder();
+        StringBuilder value = new StringBuilder();
+
+        /*
+        System.err.printf("handleNewEnvironment() %d bytes\n",
+            subnegBuffer.size());
+         */
+
+        for (int i = 1; i < subnegBuffer.size(); i++) {
+            Byte b = subnegBuffer.get(i);
+            /*
+            System.err.printf("   b: %c %d 0x%02x\n", (char)b.byteValue(),
+                b, b);
+             */
+
+            switch (state) {
+
+            case INIT:
+                // Looking for "IS"
+                if (b == 0) {
+                    state = EnvState.TYPE;
+                } else {
+                    // The other side isn't following the rules, see ya.
+                    return;
+                }
+                break;
+
+            case TYPE:
+                // Looking for "VAR" or "USERVAR"
+                if (b == 0) {
+                    // VAR
+                    state = EnvState.NAME;
+                    name = new StringBuilder();
+                } else if (b == 3) {
+                    // USERVAR
+                    state = EnvState.NAME;
+                    name = new StringBuilder();
+                } else {
+                    // The other side isn't following the rules, see ya
+                    return;
+                }
+                break;
+
+            case NAME:
+                // Looking for "VALUE" or a name byte
+                if (b == 1) {
+                    // VALUE
+                    state = EnvState.VALUE;
+                    value = new StringBuilder();
+                } else {
+                    // Take it as an environment variable name/key byte
+                    name.append((char)b.byteValue());
+                }
+
+                break;
+
+            case VALUE:
+                // Looking for "VAR", "USERVAR", or a name byte, or the end
+                if (b == 0) {
+                    // VAR
+                    state = EnvState.NAME;
+                    if (value.length() > 0) {
+                        /*
+                        System.err.printf("NAME: '%s' VALUE: '%s'\n",
+                            name, value);
+                         */
+                        newEnv.put(name.toString(), value.toString());
+                    }
+                    name = new StringBuilder();
+                } else if (b == 3) {
+                    // USERVAR
+                    state = EnvState.NAME;
+                    if (value.length() > 0) {
+                        /*
+                        System.err.printf("NAME: '%s' VALUE: '%s'\n",
+                            name, value);
+                         */
+                        newEnv.put(name.toString(), value.toString());
+                    }
+                    name = new StringBuilder();
+                } else {
+                    // Take it as an environment variable value byte
+                    value.append((char)b.byteValue());
+                }
+                break;
+
+            default:
+                throw new RuntimeException("Invalid state: " + state);
+
+            }
+        }
+
+        if ((name.length() > 0) && (value.length() > 0)) {
+            /*
+            System.err.printf("NAME: '%s' VALUE: '%s'\n", name, value);
+             */
+            newEnv.put(name.toString(), value.toString());
+        }
+
+        for (String key: newEnv.keySet()) {
+            if (key.equals("LANG")) {
+                language = newEnv.get(key);
+            }
+            if (key.equals("LOGNAME")) {
+                username = newEnv.get(key);
+            }
+            if (key.equals("USER")) {
+                username = newEnv.get(key);
+            }
+        }
+    }
+
+    /**
+     * Handle an option sub-negotiation.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    private void handleSubneg() throws IOException {
+        Byte option;
+
+        // Sanity check: there must be at least 1 byte in subnegBuffer
+        if (subnegBuffer.size() < 1) {
+            // Buffer too small: the other side is a broken telnetd, it did
+            // not send the right sub-negotiation data.  Bail out now.
+            return;
+        }
+        option = subnegBuffer.get(0);
+
+        switch (option) {
+
+        case 24:
+            // Terminal Type
+            if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 1)) {
+                // Server sent "SEND", we say "IS"
+                telnetSendTerminalType();
+            }
+            if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 0)) {
+                // Client sent "IS", record it
+                StringBuilder terminalString = new StringBuilder();
+                for (int i = 2; i < subnegBuffer.size(); i++) {
+                    terminalString.append((char)subnegBuffer.
+                        get(i).byteValue());
+                }
+                master.terminalType = terminalString.toString();
+                /*
+                System.err.printf("terminal type: '%s'\n",
+                    master.terminalType);
+                 */
+            }
+            break;
+
+        case 32:
+            // Terminal Speed
+            if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 1)) {
+                // Server sent "SEND", we say "IS"
+                telnetSendTerminalSpeed();
+            }
+            if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 0)) {
+                // Client sent "IS", record it
+                StringBuilder speedString = new StringBuilder();
+                for (int i = 2; i < subnegBuffer.size(); i++) {
+                    speedString.append((char)subnegBuffer.get(i).byteValue());
+                }
+                master.terminalSpeed = speedString.toString();
+                /*
+                System.err.printf("terminal speed: '%s'\n",
+                    master.terminalSpeed);
+                 */
+            }
+            break;
+
+        case 31:
+            // NAWS
+            if (subnegBuffer.size() >= 5) {
+                int i = 0;
+
+                i++;
+                if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                    i++;
+                }
+                int width = subnegBuffer.get(i);
+                if (width < 0) {
+                    width += 256;
+                }
+                windowWidth = width * 256;
+
+                i++;
+                if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                    i++;
+                }
+                width = subnegBuffer.get(i);
+                windowWidth += width;
+                if (width < 0) {
+                    windowWidth += 256;
+                }
+
+                i++;
+                if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                    i++;
+                }
+                int height = subnegBuffer.get(i);
+                if (height < 0) {
+                    height += 256;
+                }
+                windowHeight = height * 256;
+
+                i++;
+                if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                    i++;
+                }
+                height = subnegBuffer.get(i);
+                windowHeight += height;
+                if (height < 0) {
+                    windowHeight += 256;
+                }
+            }
+            break;
+
+        case 39:
+            // Environment
+            handleNewEnvironment();
+            break;
+
+        default:
+            // Ignore this one
+            break;
+        }
+    }
+
+    /**
+     * Reads up to len bytes of data from the input stream into an array of
+     * bytes.
+     *
+     * @param buf the buffer into which the data is read.
+     * @param off the start offset in array b at which the data is written.
+     * @param len the maximum number of bytes to read.
+     * @return the total number of bytes read into the buffer, or -1 if there
+     * is no more data because the end of the stream has been reached.
+     * @throws IOException if an I/O error occurs
+     */
+    private int readImpl(final byte[] buf, final int off,
+        final int len) throws IOException {
+
+        assert (len > 0);
+
+        // The current writing position in buf.
+        int bufN = off;
+
+        // We will keep trying to read() until we have something to return.
+        do {
+
+            byte [] buffer = null;
+            if (master.binaryMode) {
+                // Binary mode: read up to len bytes.  There will never be
+                // more bytes to pass upstream than there are bytes on the
+                // wire.
+                buffer = new byte[len];
+            } else {
+                // ASCII mode: read up to len - 2 bytes.  There may have been
+                // some combination of IAC, CR, and NUL from a previous
+                // readImpl() that could result in more bytes to pass up than
+                // are on the wire.
+                buffer = new byte[len - 2];
+            }
+
+            int bufferN = 0;
+
+            // Read some data from the other end
+            int rc = input.read(buffer);
+
+            // Check for EOF or error
+            if (rc > 0) {
+                // More data came in
+                bufferN = rc;
+            } else {
+                // EOF, just return it.
+                return rc;
+            }
+
+            // Loop through the read bytes
+            for (int i = 0; i < bufferN; i++) {
+                byte b = buffer[i];
+
+                if (subnegEnd == true) {
+                    // Looking for IAC SE to end this subnegotiation
+                    if (b == (byte) TELNET_SE) {
+                        if (iac == true) {
+                            iac = false;
+                            subnegEnd = false;
+                            handleSubneg();
+                        }
+                    } else if (b == (byte) TELNET_IAC) {
+                        if (iac == true) {
+                            // An argument to the subnegotiation option
+                            subnegBuffer.add((byte) TELNET_IAC);
+                        } else {
+                            iac = true;
+                        }
+                    } else {
+                        // An argument to the subnegotiation option
+                        subnegBuffer.add(b);
+                    }
+                    continue;
+                }
+
+                // Look for DO/DON'T/WILL/WON'T option
+                if (dowill == true) {
+
+                    // Look for option/
+                    switch (b) {
+
+                    case 0:
+                        // Binary Transmission
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use binary transmission, yay.
+                            master.binaryMode = true;
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for binary transmission.
+                            WILL(b);
+                            master.binaryMode = true;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // We're screwed, server won't do binary
+                            // transmission.
+                            master.binaryMode = false;
+                        } else {
+                            // Server demands NVT ASCII mode.
+                            master.binaryMode = false;
+                        }
+                        break;
+
+                    case 1:
+                        // Echo
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use echo, yay.
+                            master.echoMode = true;
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for echo.
+                            WILL(b);
+                            master.echoMode = true;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // We're screwed, server won't do echo.
+                            master.echoMode = false;
+                        } else {
+                            // Server demands no echo.
+                            master.echoMode = false;
+                        }
+                        break;
+
+                    case 3:
+                        // Suppress Go Ahead
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use suppress go-ahead, yay.
+                            master.goAhead = false;
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for suppress go-ahead.
+                            WILL(b);
+                            master.goAhead = false;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // We're screwed, server won't do suppress
+                            // go-ahead.
+                            master.goAhead = true;
+                        } else {
+                            // Server demands Go-Ahead mode.
+                            master.goAhead = true;
+                        }
+                        break;
+
+                    case 24:
+                        // Terminal Type - send what's in TERM
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use terminal type, yay.
+                            if (master.isServer
+                                && master.doTermType
+                            ) {
+                                requestTerminalType();
+                                master.doTermType = false;
+                            } else if (!master.isServer) {
+                                master.doTermType = true;
+                            }
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for terminal type.
+                            WILL(b);
+                            master.doTermType = true;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // We're screwed, server won't do terminal type.
+                            master.doTermType = false;
+                        } else {
+                            // Server will not listen to terminal type.
+                            master.doTermType = false;
+                        }
+                        break;
+
+                    case 31:
+                        // NAWS
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use NAWS, yay.
+                            master.doNAWS = true;
+                            // NAWS cannot be requested by the server, it is
+                            // only sent by the client.
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for NAWS.
+                            WILL(b);
+                            master.doNAWS = true;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // Server won't do NAWS.
+                            master.doNAWS = false;
+                        } else {
+                            // Server will not listen to NAWS.
+                            master.doNAWS = false;
+                        }
+                        break;
+
+                    case 32:
+                        // Terminal Speed
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use terminal speed, yay.
+                            if (master.isServer
+                                && master.doTermSpeed
+                            ) {
+                                requestTerminalSpeed();
+                                master.doTermSpeed = false;
+                            } else if (!master.isServer) {
+                                master.doTermSpeed = true;
+                            }
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for terminal speed.
+                            WILL(b);
+                            master.doTermSpeed = true;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // We're screwed, server won't do terminal speed.
+                            master.doTermSpeed = false;
+                        } else {
+                            // Server will not listen to terminal speed.
+                            master.doTermSpeed = false;
+                        }
+                        break;
+
+                    case 39:
+                        // New Environment
+                        if (dowillType == (byte) TELNET_WILL) {
+                            // Server will use NewEnvironment, yay.
+                            if (master.isServer
+                                && master.doEnvironment
+                            ) {
+                                requestEnvironment();
+                                master.doEnvironment = false;
+                            } else if (!master.isServer) {
+                                master.doEnvironment = true;
+                            }
+                        } else if (dowillType == (byte) TELNET_DO) {
+                            // Server asks for NewEnvironment.
+                            WILL(b);
+                            master.doEnvironment = true;
+                        } else if (dowillType == (byte) TELNET_WONT) {
+                            // Server won't do NewEnvironment.
+                            master.doEnvironment = false;
+                        } else {
+                            // Server will not listen to New Environment.
+                            master.doEnvironment = false;
+                        }
+                        break;
+
+
+                    default:
+                        // Other side asked for something we don't
+                        // understand.  Tell them we will not do this option.
+                        refuse(dowillType, b);
+                        break;
+                    }
+
+                    dowill = false;
+                    continue;
+                } // if (dowill == true)
+
+                // Perform read processing
+                if (b == (byte) TELNET_IAC) {
+
+                    // Telnet command
+                    if (iac == true) {
+                        // IAC IAC -> IAC
+                        buf[bufN++] = (byte) TELNET_IAC;
+                        iac = false;
+                    } else {
+                        iac = true;
+                    }
+                    continue;
+                } else {
+                    if (iac == true) {
+
+                        switch (b) {
+
+                        case (byte) TELNET_SE:
+                            // END Sub-Negotiation
+                            break;
+                        case (byte) TELNET_NOP:
+                            // NOP
+                            break;
+                        case (byte) TELNET_DM:
+                            // Data Mark
+                            break;
+                        case (byte) TELNET_BRK:
+                            // Break
+                            break;
+                        case (byte) TELNET_IP:
+                            // Interrupt Process
+                            break;
+                        case (byte) TELNET_AO:
+                            // Abort Output
+                            break;
+                        case (byte) TELNET_AYT:
+                            // Are You There?
+                            break;
+                        case (byte) TELNET_EC:
+                            // Erase Character
+                            break;
+                        case (byte) TELNET_EL:
+                            // Erase Line
+                            break;
+                        case (byte) TELNET_GA:
+                            // Go Ahead
+                            break;
+                        case (byte) TELNET_SB:
+                            // START Sub-Negotiation
+                            // From here we wait for the IAC SE
+                            subnegEnd = true;
+                            subnegBuffer.clear();
+                            break;
+                        case (byte) TELNET_WILL:
+                            // WILL
+                            dowill = true;
+                            dowillType = b;
+                            break;
+                        case (byte) TELNET_WONT:
+                            // WON'T
+                            dowill = true;
+                            dowillType = b;
+                            break;
+                        case (byte) TELNET_DO:
+                            // DO
+                            dowill = true;
+                            dowillType = b;
+                            break;
+                        case (byte) TELNET_DONT:
+                            // DON'T
+                            dowill = true;
+                            dowillType = b;
+                            break;
+                        default:
+                            // This should be equivalent to IAC NOP
+                            break;
+                        }
+                        iac = false;
+                        continue;
+
+                    } // if (iac == true)
+
+                    /*
+                     * All of the regular IAC processing is completed at this
+                     * point.  Now we need to handle the CR and CR LF cases.
+                     *
+                     * According to RFC 854, in NVT ASCII mode:
+                     *     Bare CR -> CR NUL
+                     *     CR LF -> CR LF
+                     *
+                     */
+                    if (master.binaryMode == false) {
+
+                        if (b == C_LF) {
+                            if (readCR == true) {
+                                // This is CR LF.  Send CR LF and turn the cr
+                                // flag off.
+                                buf[bufN++] = C_CR;
+                                buf[bufN++] = C_LF;
+                                readCR = false;
+                                continue;
+                            }
+                            // This is bare LF.  Send LF.
+                            buf[bufN++] = C_LF;
+                            continue;
+                        }
+
+                        if (b == C_NUL) {
+                            if (readCR == true) {
+                                // This is CR NUL.  Send CR and turn the cr
+                                // flag off.
+                                buf[bufN++] = C_CR;
+                                readCR = false;
+                                continue;
+                            }
+                            // This is bare NUL.  Send NUL.
+                            buf[bufN++] = C_NUL;
+                            continue;
+                        }
+
+                        if (b == C_CR) {
+                            if (readCR == true) {
+                                // This is CR CR.  Send a CR NUL and leave
+                                // the cr flag on.
+                                buf[bufN++] = C_CR;
+                                buf[bufN++] = C_NUL;
+                                continue;
+                            }
+                            // This is the first CR.  Set the cr flag.
+                            readCR = true;
+                            continue;
+                        }
+
+                        if (readCR == true) {
+                            // This was a bare CR in the stream.
+                            buf[bufN++] = C_CR;
+                            readCR = false;
+                        }
+
+                        // This is a regular character.  Pass it on.
+                        buf[bufN++] = b;
+                        continue;
+                    }
+
+                    /*
+                     * This is the case for any of:
+                     *
+                     *     1) A NVT ASCII character that isn't CR, LF, or
+                     *        NUL.
+                     *
+                     *     2) A NVT binary character.
+                     *
+                     * For all of these cases, we just pass the character on.
+                     */
+                    buf[bufN++] = b;
+
+                } // if (b == TELNET_IAC)
+
+            } // for (int i = 0; i < bufferN; i++)
+
+        } while (bufN == 0);
+
+        // Return bytes read
+        return bufN;
+    }
+
+}
diff --git a/src/jexer/net/TelnetOutputStream.java b/src/jexer/net/TelnetOutputStream.java
new file mode 100644 (file)
index 0000000..6e7536a
--- /dev/null
@@ -0,0 +1,260 @@
+/*
+ * 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.net;
+
+import java.io.OutputStream;
+import java.io.IOException;
+
+import static jexer.net.TelnetSocket.*;
+
+/**
+ * TelnetOutputStream works with TelnetSocket to perform the telnet protocol.
+ */
+public class TelnetOutputStream extends OutputStream {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The root TelnetSocket that has my telnet protocol state.
+     */
+    private TelnetSocket master;
+
+    /**
+     * The raw socket's OutputStream.
+     */
+    private OutputStream output;
+
+    /**
+     * When true, the last byte the caller passed to write() was a CR.
+     */
+    private boolean writeCR = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Package private constructor.
+     *
+     * @param master the master TelnetSocket
+     * @param output the underlying socket's OutputStream
+     */
+    TelnetOutputStream(final TelnetSocket master, final OutputStream output) {
+        this.master = master;
+        this.output = output;
+    }
+
+    // ------------------------------------------------------------------------
+    // OutputStrem ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Closes this output stream and releases any system resources associated
+     * with this stream.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void close() throws IOException {
+        if (output != null) {
+            output.close();
+            output = null;
+        }
+    }
+
+    /**
+     * Flushes this output stream and forces any buffered output bytes to be
+     * written out.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void flush() throws IOException {
+        if ((master.binaryMode == false) && (writeCR == true)) {
+            // The last byte sent to this.write() was a CR, which was never
+            // actually sent.  So send the CR in ascii mode, then flush.
+            // CR <anything> -> CR NULL
+            output.write(C_CR);
+            output.write(C_NUL);
+            writeCR = false;
+        }
+        output.flush();
+    }
+
+    /**
+     * Writes b.length bytes from the specified byte array to this output
+     * stream.
+     *
+     * @param b the data.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void write(final byte[] b) throws IOException {
+        writeImpl(b, 0, b.length);
+    }
+
+    /**
+     * Writes len bytes from the specified byte array starting at offset off
+     * to this output stream.
+     *
+     * @param b the data.
+     * @param off the start offset in the data.
+     * @param len the number of bytes to write.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void write(final byte[] b, final int off,
+        final int len) throws IOException {
+
+        writeImpl(b, off, len);
+    }
+
+    /**
+     * Writes the specified byte to this output stream.
+     *
+     * @param b the byte to write.
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public void write(final int b) throws IOException {
+        byte [] bytes = new byte[1];
+        bytes[0] = (byte) b;
+        writeImpl(bytes, 0, 1);
+    }
+
+    // ------------------------------------------------------------------------
+    // TelnetOutputStrem ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Writes b.length bytes from the specified byte array to this output
+     * stream.  Note package private access.
+     *
+     * @param b the data.
+     * @throws IOException if an I/O error occurs
+     */
+    void rawWrite(final byte[] b) throws IOException {
+        output.write(b, 0, b.length);
+    }
+
+    /**
+     * Writes len bytes from the specified byte array starting at offset off
+     * to this output stream.
+     *
+     * @param b the data.
+     * @param off the start offset in the data.
+     * @param len the number of bytes to write.
+     * @throws IOException if an I/O error occurs
+     */
+    private void writeImpl(final byte[] b, final int off,
+        final int len) throws IOException {
+
+        byte [] writeBuffer = new byte[Math.max(len, 4)];
+        int writeBufferI = 0;
+
+        for (int i = 0; i < len; i++) {
+            if (writeBufferI >= writeBuffer.length - 4) {
+                // Flush what we have generated so far and reset the buffer,
+                // because the next byte could generate up to 4 output bytes
+                // (CR <something> <IAC> <IAC>).
+                output.write(writeBuffer, 0, writeBufferI);
+                writeBufferI = 0;
+            }
+
+            // Pull the next byte
+            byte ch = b[i + off];
+
+            if (master.binaryMode == true) {
+
+                if (ch == TELNET_IAC) {
+                    // IAC -> IAC IAC
+                    writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+                    writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+                } else {
+                    // Anything else -> just send
+                    writeBuffer[writeBufferI++] = ch;
+                }
+                continue;
+            }
+
+            // Non-binary mode: more complicated.  We use writeCR to handle
+            // the case that the last byte of b was a CR.
+
+            // Bare carriage return -> CR NUL
+            if (ch == C_CR) {
+                if (writeCR == true) {
+                    // Flush the previous CR to the stream.
+                    // CR <anything> -> CR NULL
+                    writeBuffer[writeBufferI++] = (byte) C_CR;
+                    writeBuffer[writeBufferI++] = (byte) C_NUL;
+                }
+                writeCR = true;
+            } else if (ch == C_LF) {
+                if (writeCR == true) {
+                    // CR LF -> CR LF
+                    writeBuffer[writeBufferI++] = (byte) C_CR;
+                    writeBuffer[writeBufferI++] = (byte) C_LF;
+                    writeCR = false;
+                } else {
+                    // Bare LF -> LF
+                    writeBuffer[writeBufferI++] = ch;
+                }
+            } else if (ch == TELNET_IAC) {
+                if (writeCR == true) {
+                    // CR <anything> -> CR NULL
+                    writeBuffer[writeBufferI++] = (byte) C_CR;
+                    writeBuffer[writeBufferI++] = (byte) C_NUL;
+                    writeCR = false;
+                }
+                // IAC -> IAC IAC
+                writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+                writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+            } else {
+                if (writeCR == true) {
+                    // CR <anything> -> CR NULL
+                    writeBuffer[writeBufferI++] = (byte) C_CR;
+                    writeBuffer[writeBufferI++] = (byte) C_NUL;
+                    writeCR = false;
+                } else {
+                    // Normal character */
+                    writeBuffer[writeBufferI++] = ch;
+                }
+            }
+
+        } // while (i < userbuf.length)
+
+        if (writeBufferI > 0) {
+            // Flush what we have generated so far and reset the buffer.
+            output.write(writeBuffer, 0, writeBufferI);
+        }
+    }
+
+}
diff --git a/src/jexer/net/TelnetServerSocket.java b/src/jexer/net/TelnetServerSocket.java
new file mode 100644 (file)
index 0000000..3c5b307
--- /dev/null
@@ -0,0 +1,129 @@
+/*
+ * 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.net;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+
+/**
+ * This class provides a ServerSocket that return TelnetSocket's in accept().
+ */
+public class TelnetServerSocket extends ServerSocket {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Creates an unbound server socket.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    public TelnetServerSocket() throws IOException {
+        super();
+    }
+
+    /**
+     * Creates a server socket, bound to the specified port.
+     *
+     * @param port the port number, or 0 to use a port number that is
+     * automatically allocated.
+     * @throws IOException if an I/O error occurs
+     */
+    public TelnetServerSocket(final int port) throws IOException {
+        super(port);
+    }
+
+    /**
+     * Creates a server socket and binds it to the specified local port
+     * number, with the specified backlog.
+     *
+     * @param port the port number, or 0 to use a port number that is
+     * automatically allocated.
+     * @param backlog requested maximum length of the queue of incoming
+     * connections.
+     * @throws IOException if an I/O error occurs
+     */
+    public TelnetServerSocket(final int port,
+        final int backlog) throws IOException {
+
+        super(port, backlog);
+    }
+
+    /**
+     * Create a server with the specified port, listen backlog, and local IP
+     * address to bind to.
+     *
+     * @param port the port number, or 0 to use a port number that is
+     * automatically allocated.
+     * @param backlog requested maximum length of the queue of incoming
+     * connections.
+     * @param bindAddr the local InetAddress the server will bind to
+     * @throws IOException if an I/O error occurs
+     */
+    public TelnetServerSocket(final int port, final int backlog,
+        final InetAddress bindAddr) throws IOException {
+
+        super(port, backlog, bindAddr);
+    }
+
+    // ------------------------------------------------------------------------
+    // ServerSocket -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Listens for a connection to be made to this socket and accepts it. The
+     * method blocks until a connection is made.
+     *
+     * @return the new Socket
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public Socket accept() throws IOException {
+        if (isClosed()) {
+            throw new SocketException("Socket is closed");
+        }
+        if (!isBound()) {
+            throw new SocketException("Socket is not bound");
+        }
+
+        Socket socket = new TelnetSocket();
+        implAccept(socket);
+        return socket;
+    }
+
+}
diff --git a/src/jexer/net/TelnetSocket.java b/src/jexer/net/TelnetSocket.java
new file mode 100644 (file)
index 0000000..ac8a278
--- /dev/null
@@ -0,0 +1,203 @@
+/*
+ * 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.net;
+
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.Socket;
+
+/**
+ * This class provides a Socket that performs the telnet protocol to both
+ * establish an 8-bit clean no echo channel and expose window resize events
+ * to the Jexer ECMA48 backend.
+ */
+public class TelnetSocket extends Socket {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // Telnet protocol special characters.  Note package private access.
+    static final int TELNET_SE         = 240;
+    static final int TELNET_NOP        = 241;
+    static final int TELNET_DM         = 242;
+    static final int TELNET_BRK        = 243;
+    static final int TELNET_IP         = 244;
+    static final int TELNET_AO         = 245;
+    static final int TELNET_AYT        = 246;
+    static final int TELNET_EC         = 247;
+    static final int TELNET_EL         = 248;
+    static final int TELNET_GA         = 249;
+    static final int TELNET_SB         = 250;
+    static final int TELNET_WILL       = 251;
+    static final int TELNET_WONT       = 252;
+    static final int TELNET_DO         = 253;
+    static final int TELNET_DONT       = 254;
+    static final int TELNET_IAC        = 255;
+    static final int C_NUL             = 0x00;
+    static final int C_LF              = 0x0A;
+    static final int C_CR              = 0x0D;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The telnet-aware socket InputStream.
+     */
+    private TelnetInputStream input;
+
+    /**
+     * The telnet-aware socket OutputStream.
+     */
+    private TelnetOutputStream output;
+
+
+    /**
+     * If true, this is a server socket (i.e. created by accept()).
+     */
+    boolean isServer = true;
+
+    /**
+     * If true, telnet ECHO mode is set such that local echo is off and
+     * remote echo is on.  This is appropriate for server sockets.
+     */
+    boolean echoMode = false;
+
+    /**
+     * If true, telnet BINARY mode is enabled.  We always want this to
+     * ensure a Unicode-safe stream.
+     */
+    boolean binaryMode = false;
+
+    /**
+     * If true, the SUPPRESS-GO-AHEAD option is enabled.  We always want
+     * this.
+     */
+    boolean goAhead = true;
+
+    /**
+     * If true, request the client terminal type.
+     */
+    boolean doTermType = true;
+
+    /**
+     * If true, request the client terminal speed.
+     */
+    boolean doTermSpeed = true;
+
+    /**
+     * If true, request the Negotiate About Window Size option to
+     * determine the client text width/height.
+     */
+    boolean doNAWS = true;
+
+    /**
+     * If true, request the New Environment option to obtain the client
+     * LOGNAME, USER, and LANG variables.
+     */
+    boolean doEnvironment = true;
+
+    /**
+     * The terminal type reported by the client.
+     */
+    String terminalType = "";
+
+    /**
+     * The terminal speed reported by the client.
+     */
+    String terminalSpeed = "";
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Creates a Socket that knows the telnet protocol.  Note package private
+     * access, this is only used by TelnetServerSocket.
+     *
+     * @throws IOException if an I/O error occurs
+     */
+    TelnetSocket() throws IOException {
+        super();
+    }
+
+    // ------------------------------------------------------------------------
+    // Socket -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Returns an input stream for this socket.
+     *
+     * @return the input stream
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public InputStream getInputStream() throws IOException {
+        if (input == null) {
+            assert (output == null);
+            output = new TelnetOutputStream(this, super.getOutputStream());
+            input = new TelnetInputStream(this, super.getInputStream(), output);
+            input.telnetSendOptions();
+        }
+        return input;
+    }
+
+    /**
+     * Returns an output stream for this socket.
+     *
+     * @return the output stream
+     * @throws IOException if an I/O error occurs
+     */
+    @Override
+    public OutputStream getOutputStream() throws IOException {
+        if (output == null) {
+            assert (input == null);
+            output = new TelnetOutputStream(this, super.getOutputStream());
+            input = new TelnetInputStream(this, super.getInputStream(), output);
+            input.telnetSendOptions();
+        }
+        return output;
+    }
+
+    // ------------------------------------------------------------------------
+    // TelnetSocket -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * See if telnet server/client is in ASCII mode.
+     *
+     * @return if true, this connection is in ASCII mode
+     */
+    public boolean isAscii() {
+        return (!binaryMode);
+    }
+
+}
diff --git a/src/jexer/net/package-info.java b/src/jexer/net/package-info.java
new file mode 100644 (file)
index 0000000..5d738fb
--- /dev/null
@@ -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
+ */
+
+/**
+ * A Telnet-aware ServerSocket that establishes an 8-bit clean data channel.
+ */
+package jexer.net;
diff --git a/src/jexer/package-info.java b/src/jexer/package-info.java
new file mode 100644 (file)
index 0000000..300f973
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * 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
+ */
+
+/**
+ * Jexer - Java Text User Interface library
+ *
+ * <p>
+ * This library is a text-based windowing system loosely reminiscent of
+ * Borland's <a href="http://en.wikipedia.org/wiki/Turbo_Vision">Turbo
+ * Vision</a> library.  Jexer's goal is to enable people to get up and
+ * running with minimum hassle and lots of polish.  A very quick "Hello
+ * World" application can be created as simply as this:
+ *
+ * <pre>
+ * {@code
+ * import jexer.TApplication;
+ *
+ * public class MyApplication extends TApplication {
+ *
+ *     public MyApplication() throws Exception {
+ *         super(BackendType.XTERM);
+ *
+ *         // Create standard menus for Tool, File, and Window.
+ *         addToolMenu();
+ *         addFileMenu();
+ *         addWindowMenu();
+ *     }
+ *
+ *     public static void main(String [] args) throws Exception {
+ *         MyApplication app = new MyApplication();
+ *         app.run();
+ *     }
+ * }
+ * }
+ * </pre>
+ */
+package jexer;
diff --git a/src/jexer/resources/help.xml b/src/jexer/resources/help.xml
new file mode 100644 (file)
index 0000000..7c68c02
--- /dev/null
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<help>
+  <name>Jexer Help File</name>
+  <author>Kevin Lamonte</author>
+  <version>1.0.0</version>
+  <date>Jan 1, 2020</date>
+  <topics>
+    <topic title="Help">
+      <text>
+        This [window](Windows) does not have a specific help topic.
+        See [here](Help On Help) for general information on using the
+        help system.
+      </text>
+    </topic>
+    <topic title="Help On Help">
+      <text>
+        The #{help} system...
+
+      </text>
+    </topic>
+
+    <topic title="Menus">
+      <text>
+        #{Menus} do ...
+      </text>
+    </topic>
+
+    <topic title="Windows">
+      <text>
+        #{Windows} do ...
+      </text>
+    </topic>
+
+    <topic title="Editing Text">
+      <text>
+        The #{text editing} [window](Windows)...
+      </text>
+    </topic>
+
+    <topic title="Editing Tables">
+      <text></text>
+    </topic>
+
+    <topic title="Terminal Window">
+      <text>
+        The terminal window ...
+      </text>
+    </topic>
+
+    <topic title="Copyright Infomation">
+      <text>
+        Copyright (C) 2019 Kevin Lamonte
+
+        Available to all under the MIT License.
+    </text>
+    </topic>
+
+  </topics>
+</help>
diff --git a/src/jexer/resources/jexer_logo_128.png b/src/jexer/resources/jexer_logo_128.png
new file mode 100644 (file)
index 0000000..5c3a813
Binary files /dev/null and b/src/jexer/resources/jexer_logo_128.png differ
diff --git a/src/jexer/resources/terminus-ttf-4.39/COPYING b/src/jexer/resources/terminus-ttf-4.39/COPYING
new file mode 100644 (file)
index 0000000..f1ea357
--- /dev/null
@@ -0,0 +1,97 @@
+Copyright (c) 2010 Dimitar Toshkov Zhekov,
+with Reserved Font Name "Terminus Font".
+
+Copyright (c) 2011 Tilman Blumenbach,
+with Reserved Font Name "Terminus (TTF)".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf
new file mode 100644 (file)
index 0000000..f4bb6b2
Binary files /dev/null and b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf differ
diff --git a/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf
new file mode 100644 (file)
index 0000000..06700de
Binary files /dev/null and b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf differ
diff --git a/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf
new file mode 100644 (file)
index 0000000..e80d0c0
Binary files /dev/null and b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf differ
diff --git a/src/jexer/screenshots/screenshot1_old.png b/src/jexer/screenshots/screenshot1_old.png
new file mode 100644 (file)
index 0000000..10e9ec2
Binary files /dev/null and b/src/jexer/screenshots/screenshot1_old.png differ
diff --git a/src/jexer/teditor/Document.java b/src/jexer/teditor/Document.java
new file mode 100644 (file)
index 0000000..b4a9a3b
--- /dev/null
@@ -0,0 +1,823 @@
+/*
+ * 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.teditor;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+
+/**
+ * A Document represents a text file, as a collection of lines.
+ */
+public class Document {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The list of lines.
+     */
+    private ArrayList<Line> lines = new ArrayList<Line>();
+
+    /**
+     * The current line number being edited.  Note that this is 0-based, the
+     * first line is line number 0.
+     */
+    private int lineNumber = 0;
+
+    /**
+     * The overwrite flag.  When true, characters overwrite data.
+     */
+    private boolean overwrite = false;
+
+    /**
+     * If true, the document has been edited.
+     */
+    private boolean dirty = false;
+
+    /**
+     * The default color for the TEditor class.
+     */
+    private CellAttributes defaultColor = null;
+
+    /**
+     * The text highlighter to use.
+     */
+    private Highlighter highlighter = new Highlighter();
+
+    /**
+     * The tab stop size.
+     */
+    private int tabSize = 8;
+
+    /**
+     * If true, backspace at an indent level goes back a full indent level.
+     * If false, backspace always goes back one column.
+     */
+    private boolean backspaceUnindents = false;
+
+    /**
+     * If true, save files with tab characters.  If false, convert tabs to
+     * spaces when saving files.
+     */
+    private boolean saveWithTabs = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Construct a new Document from an existing text string.
+     *
+     * @param str the text string
+     * @param defaultColor the color for unhighlighted text
+     */
+    public Document(final String str, final CellAttributes defaultColor) {
+        this.defaultColor = defaultColor;
+
+        // Set colors to resemble the Borland IDE colors, but for Java
+        // language keywords.
+        highlighter.setJavaColors();
+
+        String [] rawLines = str.split("\n");
+        for (int i = 0; i < rawLines.length; i++) {
+            lines.add(new Line(rawLines[i], this.defaultColor, highlighter));
+        }
+    }
+
+    /**
+     * Private constructor used by dup().
+     */
+    private Document() {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // Document ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create a duplicate instance.
+     *
+     * @return duplicate intance
+     */
+    public Document dup() {
+        Document other = new Document();
+        for (Line line: lines) {
+            other.lines.add(line.dup());
+        }
+        other.lineNumber = lineNumber;
+        other.overwrite = overwrite;
+        other.dirty = dirty;
+        other.defaultColor = defaultColor;
+        other.highlighter.setTo(highlighter);
+        return other;
+    }
+
+    /**
+     * Get the overwrite flag.
+     *
+     * @return true if addChar() overwrites data, false if it inserts
+     */
+    public boolean isOverwrite() {
+        return overwrite;
+    }
+
+    /**
+     * Get the dirty value.
+     *
+     * @return true if the buffer is dirty
+     */
+    public boolean isDirty() {
+        return dirty;
+    }
+
+    /**
+     * Unset the dirty flag.
+     */
+    public void setNotDirty() {
+        dirty = false;
+    }
+
+    /**
+     * Save contents to file.
+     *
+     * @param filename file to save to
+     * @throws IOException if a java.io operation throws
+     */
+    public void saveToFilename(final String filename) throws IOException {
+        OutputStreamWriter output = null;
+        try {
+            output = new OutputStreamWriter(new FileOutputStream(filename),
+                "UTF-8");
+
+            for (Line line: lines) {
+                if (saveWithTabs) {
+                    output.write(convertSpacesToTabs(line.getRawString()));
+                } else {
+                    output.write(line.getRawString());
+                }
+                output.write("\n");
+            }
+
+            dirty = false;
+        }
+        finally {
+            if (output != null) {
+                output.close();
+            }
+        }
+    }
+
+    /**
+     * Set the overwrite flag.
+     *
+     * @param overwrite true if addChar() should overwrite data, false if it
+     * should insert
+     */
+    public void setOverwrite(final boolean overwrite) {
+        this.overwrite = overwrite;
+    }
+
+    /**
+     * Get the current line number being edited.
+     *
+     * @return the line number.  Note that this is 0-based: 0 is the first
+     * line.
+     */
+    public int getLineNumber() {
+        return lineNumber;
+    }
+
+    /**
+     * Get the current editing line.
+     *
+     * @return the line
+     */
+    public Line getCurrentLine() {
+        return lines.get(lineNumber);
+    }
+
+    /**
+     * Get a specific line by number.
+     *
+     * @param lineNumber the line number.  Note that this is 0-based: 0 is
+     * the first line.
+     * @return the line
+     */
+    public Line getLine(final int lineNumber) {
+        return lines.get(lineNumber);
+    }
+
+    /**
+     * Set the current line number being edited.
+     *
+     * @param n the line number.  Note that this is 0-based: 0 is the first
+     * line.
+     */
+    public void setLineNumber(final int n) {
+        if ((n < 0) || (n > lines.size())) {
+            throw new IndexOutOfBoundsException("Lines array size is " +
+                lines.size() + ", requested index " + n);
+        }
+        lineNumber = n;
+    }
+
+    /**
+     * Get the current cursor position of the editing line.
+     *
+     * @return the cursor position
+     */
+    public int getCursor() {
+        return lines.get(lineNumber).getCursor();
+    }
+
+    /**
+     * Get the character at the current cursor position in the text.
+     *
+     * @return the character, or -1 if the cursor is at the end of the line
+     */
+    public int getChar() {
+        return lines.get(lineNumber).getChar();
+    }
+
+    /**
+     * Set the current cursor position of the editing line.  0-based.
+     *
+     * @param cursor the new cursor position
+     */
+    public void setCursor(final int cursor) {
+        if (cursor >= lines.get(lineNumber).getDisplayLength()) {
+            lines.get(lineNumber).end();
+        } else {
+            lines.get(lineNumber).setCursor(cursor);
+        }
+    }
+
+    /**
+     * Increment the line number by one.  If at the last line, do nothing.
+     *
+     * @return true if the editing line changed
+     */
+    public boolean down() {
+        if (lineNumber < lines.size() - 1) {
+            int x = lines.get(lineNumber).getCursor();
+            lineNumber++;
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
+                lines.get(lineNumber).end();
+            } else {
+                lines.get(lineNumber).setCursor(x);
+            }
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Increment the line number by n.  If n would go past the last line,
+     * increment only to the last line.
+     *
+     * @param n the number of lines to increment by
+     * @return true if the editing line changed
+     */
+    public boolean down(final int n) {
+        if (lineNumber < lines.size() - 1) {
+            int x = lines.get(lineNumber).getCursor();
+            lineNumber += n;
+            if (lineNumber > lines.size() - 1) {
+                lineNumber = lines.size() - 1;
+            }
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
+                lines.get(lineNumber).end();
+            } else {
+                lines.get(lineNumber).setCursor(x);
+            }
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Decrement the line number by one.  If at the first line, do nothing.
+     *
+     * @return true if the editing line changed
+     */
+    public boolean up() {
+        if (lineNumber > 0) {
+            int x = lines.get(lineNumber).getCursor();
+            lineNumber--;
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
+                lines.get(lineNumber).end();
+            } else {
+                lines.get(lineNumber).setCursor(x);
+            }
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Decrement the line number by n.  If n would go past the first line,
+     * decrement only to the first line.
+     *
+     * @param n the number of lines to decrement by
+     * @return true if the editing line changed
+     */
+    public boolean up(final int n) {
+        if (lineNumber > 0) {
+            int x = lines.get(lineNumber).getCursor();
+            lineNumber -= n;
+            if (lineNumber < 0) {
+                lineNumber = 0;
+            }
+            if (x >= lines.get(lineNumber).getDisplayLength()) {
+                lines.get(lineNumber).end();
+            } else {
+                lines.get(lineNumber).setCursor(x);
+            }
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Decrement the cursor by one.  If at the first column on the first
+     * line, do nothing.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean left() {
+        if (!lines.get(lineNumber).left()) {
+            // We are on the leftmost column, wrap
+            if (up()) {
+                end();
+            } else {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Increment the cursor by one.  If at the last column on the last line,
+     * do nothing.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean right() {
+        if (!lines.get(lineNumber).right()) {
+            // We are on the rightmost column, wrap
+            if (down()) {
+                home();
+            } else {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Go back to the beginning of this word if in the middle, or the
+     * beginning of the previous word.
+     */
+    public void backwardsWord() {
+
+        // If at the beginning of a word already, push past it.
+        if ((getChar() != -1)
+            && (getRawLine().length() > 0)
+            && !Character.isWhitespace((char) getChar())
+        ) {
+            left();
+        }
+
+        // int line = lineNumber;
+        while ((getChar() == -1)
+            || (getRawLine().length() == 0)
+            || Character.isWhitespace((char) getChar())
+        ) {
+            if (left() == false) {
+                return;
+            }
+        }
+
+
+        assert (getChar() != -1);
+
+        if (!Character.isWhitespace((char) getChar())
+            && (getRawLine().length() > 0)
+        ) {
+            // Advance until at the beginning of the document or a whitespace
+            // is encountered.
+            while (!Character.isWhitespace((char) getChar())) {
+                int line = lineNumber;
+                if (left() == false) {
+                    // End of document, bail out.
+                    return;
+                }
+                if (lineNumber != line) {
+                    // We wrapped a line.  Here that counts as whitespace.
+                    right();
+                    return;
+                }
+            }
+        }
+
+        // We went one past the word, push back to the first character of
+        // that word.
+        right();
+        return;
+    }
+
+    /**
+     * Go to the beginning of the next word.
+     */
+    public void forwardsWord() {
+        int line = lineNumber;
+        while ((getChar() == -1)
+            || (getRawLine().length() == 0)
+        ) {
+            if (right() == false) {
+                return;
+            }
+            if (lineNumber != line) {
+                // We wrapped a line.  Here that counts as whitespace.
+                if (!Character.isWhitespace((char) getChar())) {
+                    // We found a character immediately after the line.
+                    // Done!
+                    return;
+                }
+                // Still looking...
+                line = lineNumber;
+            }
+        }
+        assert (getChar() != -1);
+
+        if (!Character.isWhitespace((char) getChar())
+            && (getRawLine().length() > 0)
+        ) {
+            // Advance until at the end of the document or a whitespace is
+            // encountered.
+            while (!Character.isWhitespace((char) getChar())) {
+                line = lineNumber;
+                if (right() == false) {
+                    // End of document, bail out.
+                    return;
+                }
+                if (lineNumber != line) {
+                    // We wrapped a line.  Here that counts as whitespace.
+                    if (!Character.isWhitespace((char) getChar())
+                        && (getRawLine().length() > 0)
+                    ) {
+                        // We found a character immediately after the line.
+                        // Done!
+                        return;
+                    }
+                    break;
+                }
+            }
+        }
+
+        while ((getChar() == -1)
+            || (getRawLine().length() == 0)
+        ) {
+            if (right() == false) {
+                return;
+            }
+            if (lineNumber != line) {
+                // We wrapped a line.  Here that counts as whitespace.
+                if (!Character.isWhitespace((char) getChar())) {
+                    // We found a character immediately after the line.
+                    // Done!
+                    return;
+                }
+                // Still looking...
+                line = lineNumber;
+            }
+        }
+        assert (getChar() != -1);
+
+        if (Character.isWhitespace((char) getChar())) {
+            // Advance until at the end of the document or a non-whitespace
+            // is encountered.
+            while (Character.isWhitespace((char) getChar())) {
+                if (right() == false) {
+                    // End of document, bail out.
+                    return;
+                }
+            }
+            return;
+        }
+
+        // We wrapped the line to get here.
+        return;
+    }
+
+    /**
+     * Get the raw string that matches this line.
+     *
+     * @return the string
+     */
+    public String getRawLine() {
+        return lines.get(lineNumber).getRawString();
+    }
+
+    /**
+     * Go to the first column of this line.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean home() {
+        return lines.get(lineNumber).home();
+    }
+
+    /**
+     * Go to the last column of this line.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean end() {
+        return lines.get(lineNumber).end();
+    }
+
+    /**
+     * Delete the character under the cursor.
+     */
+    public void del() {
+        dirty = true;
+        int cursor = lines.get(lineNumber).getCursor();
+        if (cursor < lines.get(lineNumber).getDisplayLength() - 1) {
+            lines.get(lineNumber).del();
+        } else if (lineNumber < lines.size() - 2) {
+            // Join two lines
+            StringBuilder newLine = new StringBuilder(lines.
+                get(lineNumber).getRawString());
+            newLine.append(lines.get(lineNumber + 1).getRawString());
+            lines.set(lineNumber, new Line(newLine.toString(),
+                    defaultColor, highlighter));
+            lines.get(lineNumber).setCursor(cursor);
+            lines.remove(lineNumber + 1);
+        }
+    }
+
+    /**
+     * Delete the character immediately preceeding the cursor.
+     */
+    public void backspace() {
+        dirty = true;
+        int cursor = lines.get(lineNumber).getCursor();
+        if (cursor > 0) {
+            lines.get(lineNumber).backspace(tabSize, backspaceUnindents);
+        } else if (lineNumber > 0) {
+            // Join two lines
+            lineNumber--;
+            String firstLine = lines.get(lineNumber).getRawString();
+            if (firstLine.length() > 0) {
+                // Backspacing combining two lines
+                StringBuilder newLine = new StringBuilder(firstLine);
+                newLine.append(lines.get(lineNumber + 1).getRawString());
+                lines.set(lineNumber, new Line(newLine.toString(),
+                        defaultColor, highlighter));
+                lines.get(lineNumber).setCursor(firstLine.length());
+                lines.remove(lineNumber + 1);
+            } else {
+                // Backspacing an empty line
+                lines.remove(lineNumber);
+                lines.get(lineNumber).setCursor(0);
+            }
+        }
+    }
+
+    /**
+     * Split the current line into two, like pressing the enter key.
+     */
+    public void enter() {
+        dirty = true;
+        int cursor = lines.get(lineNumber).getRawCursor();
+        String original = lines.get(lineNumber).getRawString();
+        String firstLine = original.substring(0, cursor);
+        String secondLine = original.substring(cursor);
+        lines.add(lineNumber + 1, new Line(secondLine, defaultColor,
+                highlighter));
+        lines.set(lineNumber, new Line(firstLine, defaultColor, highlighter));
+        lineNumber++;
+        lines.get(lineNumber).home();
+    }
+
+    /**
+     * Replace or insert a character at the cursor, depending on overwrite
+     * flag.
+     *
+     * @param ch the character to replace or insert
+     */
+    public void addChar(final int ch) {
+        dirty = true;
+        if (overwrite) {
+            lines.get(lineNumber).replaceChar(ch);
+        } else {
+            lines.get(lineNumber).addChar(ch);
+        }
+    }
+
+    /**
+     * Get the tab stop size.
+     *
+     * @return the tab stop size
+     */
+    public int getTabSize() {
+        return tabSize;
+    }
+
+    /**
+     * Set the tab stop size.
+     *
+     * @param tabSize the new tab stop size
+     */
+    public void setTabSize(final int tabSize) {
+        this.tabSize = tabSize;
+    }
+
+    /**
+     * Set the backspace unindent option.
+     *
+     * @param backspaceUnindents If true, backspace at an indent level goes
+     * back a full indent level.  If false, backspace always goes back one
+     * column.
+     */
+    public void setBackspaceUnindents(final boolean backspaceUnindents) {
+        this.backspaceUnindents = backspaceUnindents;
+    }
+
+    /**
+     * Set the save with tabs option.
+     *
+     * @param saveWithTabs If true, save files with tab characters.  If
+     * false, convert tabs to spaces when saving files.
+     */
+    public void setSaveWithTabs(final boolean saveWithTabs) {
+        this.saveWithTabs = saveWithTabs;
+    }
+
+    /**
+     * Handle the tab character.
+     */
+    public void tab() {
+        if (overwrite) {
+            del();
+        }
+        lines.get(lineNumber).tab(tabSize);
+    }
+
+    /**
+     * Handle the backtab (shift-tab) character.
+     */
+    public void backTab() {
+        lines.get(lineNumber).backTab(tabSize);
+    }
+
+    /**
+     * Get a (shallow) copy of the list of lines.
+     *
+     * @return the list of lines
+     */
+    public List<Line> getLines() {
+        return new ArrayList<Line>(lines);
+    }
+
+    /**
+     * Get the number of lines.
+     *
+     * @return the number of lines
+     */
+    public int getLineCount() {
+        return lines.size();
+    }
+
+    /**
+     * Compute the maximum line length for this document.
+     *
+     * @return the number of cells needed to display the longest line
+     */
+    public int getLineLengthMax() {
+        int n = 0;
+        for (Line line : lines) {
+            if (line.getDisplayLength() > n) {
+                n = line.getDisplayLength();
+            }
+        }
+        return n;
+    }
+
+    /**
+     * Get the current line length.
+     *
+     * @return the number of cells needed to display the current line
+     */
+    public int getLineLength() {
+        return lines.get(lineNumber).getDisplayLength();
+    }
+
+    /**
+     * Get the entire contents of the document as one string.
+     *
+     * @return the document contents
+     */
+    public String getText() {
+        StringBuilder sb = new StringBuilder();
+        for (Line line: getLines()) {
+            sb.append(line.getRawString());
+            sb.append("\n");
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Trim trailing whitespace from lines and trailing empty
+     * lines from the document.
+     */
+    public void cleanWhitespace() {
+        for (Line line: getLines()) {
+            line.trimRight();
+        }
+        if (lines.size() == 0) {
+            return;
+        }
+        while (lines.get(lines.size() - 1).length() == 0) {
+            lines.remove(lines.size() - 1);
+        }
+        if (lineNumber > lines.size() - 1) {
+            lineNumber = lines.size() - 1;
+        }
+    }
+
+    /**
+     * Set keyword highlighting.
+     *
+     * @param enabled if true, enable keyword highlighting
+     */
+    public void setHighlighting(final boolean enabled) {
+        highlighter.setEnabled(enabled);
+        for (Line line: getLines()) {
+            line.scanLine();
+        }
+    }
+
+    /**
+     * Convert a string with leading spaces to a mix of tabs and spaces.
+     *
+     * @param string the string to convert
+     */
+    private String convertSpacesToTabs(final String string) {
+        if (string.length() == 0) {
+            return string;
+        }
+
+        int start = 0;
+        while (string.charAt(start) == ' ') {
+            start++;
+        }
+        int tabCount = start / 8;
+        if (tabCount == 0) {
+            return string;
+        }
+
+        StringBuilder sb = new StringBuilder(string.length());
+
+        for (int i = 0; i < tabCount; i++) {
+            sb.append('\t');
+        }
+        sb.append(string.substring(tabCount * 8));
+        return sb.toString();
+    }
+
+}
diff --git a/src/jexer/teditor/Highlighter.java b/src/jexer/teditor/Highlighter.java
new file mode 100644 (file)
index 0000000..23ee900
--- /dev/null
@@ -0,0 +1,194 @@
+/*
+ * 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.teditor;
+
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.Color;
+
+/**
+ * Highlighter provides color choices for certain text strings.
+ */
+public class Highlighter {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The highlighter colors.
+     */
+    private SortedMap<String, CellAttributes> colors;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor sets the theme to the default.
+     */
+    public Highlighter() {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // Highlighter ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set keyword highlighting.
+     *
+     * @param enabled if true, enable keyword highlighting
+     */
+    public void setEnabled(final boolean enabled) {
+        if (enabled) {
+            setJavaColors();
+        } else {
+            colors = null;
+        }
+    }
+
+    /**
+     * Set my field values to that's field.
+     *
+     * @param rhs an instance of Highlighter
+     */
+    public void setTo(final Highlighter rhs) {
+        colors = new TreeMap<String, CellAttributes>();
+        colors.putAll(rhs.colors);
+    }
+
+    /**
+     * See if this is a character that should split a word.
+     *
+     * @param ch the character
+     * @return true if the word should be split
+     */
+    public boolean shouldSplit(final int ch) {
+        // For now, split on punctuation
+        String punctuation = "'\"\\<>{}[]!@#$%^&*();:.,-+/*?";
+        if (ch < 0x100) {
+            if (punctuation.indexOf((char) ch) != -1) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Retrieve the CellAttributes for a named theme color.
+     *
+     * @param name theme color name, e.g. "twindow.border"
+     * @return color associated with name, e.g. bold yellow on blue
+     */
+    public CellAttributes getColor(final String name) {
+        if (colors == null) {
+            return null;
+        }
+        CellAttributes attr = colors.get(name);
+        return attr;
+    }
+
+    /**
+     * Sets to defaults that resemble the Borland IDE colors.
+     */
+    public void setJavaColors() {
+        colors = new TreeMap<String, CellAttributes>();
+
+        CellAttributes color;
+
+        String [] types = {
+            "boolean", "byte", "short", "int", "long", "char", "float",
+            "double", "void",
+        };
+        color = new CellAttributes();
+        color.setForeColor(Color.GREEN);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        for (String str: types) {
+            colors.put(str, color);
+        }
+
+        String [] modifiers = {
+            "abstract", "final", "native", "private", "protected", "public",
+            "static", "strictfp", "synchronized", "transient", "volatile",
+        };
+        color = new CellAttributes();
+        color.setForeColor(Color.WHITE);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        for (String str: modifiers) {
+            colors.put(str, color);
+        }
+
+        String [] keywords = {
+            "new", "class", "interface", "extends", "implements",
+            "if", "else", "do", "while", "for", "break", "continue",
+            "switch", "case", "default",
+        };
+        color = new CellAttributes();
+        color.setForeColor(Color.YELLOW);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        for (String str: keywords) {
+            colors.put(str, color);
+        }
+
+        String [] operators = {
+            "[", "]", "(", ")", "{", "}",
+            "*", "-", "+", "/", "=", "%",
+            "^", "&", "!", "<<", ">>", "<<<", ">>>",
+            "&&", "||",
+            ">", "<", ">=", "<=", "!=", "==",
+            ",", ";", ".", "?", ":",
+        };
+        color = new CellAttributes();
+        color.setForeColor(Color.CYAN);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        for (String str: operators) {
+            colors.put(str, color);
+        }
+
+        String [] packageKeywords = {
+            "package", "import",
+        };
+        color = new CellAttributes();
+        color.setForeColor(Color.GREEN);
+        color.setBackColor(Color.BLUE);
+        color.setBold(true);
+        for (String str: packageKeywords) {
+            colors.put(str, color);
+        }
+
+    }
+
+}
diff --git a/src/jexer/teditor/Line.java b/src/jexer/teditor/Line.java
new file mode 100644 (file)
index 0000000..b5c980a
--- /dev/null
@@ -0,0 +1,498 @@
+/*
+ * 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.teditor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+
+/**
+ * A Line represents a single line of text on the screen, as a collection of
+ * words.
+ */
+public class Line {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The list of words.
+     */
+    private ArrayList<Word> words = new ArrayList<Word>();
+
+    /**
+     * The default color for the TEditor class.
+     */
+    private CellAttributes defaultColor = null;
+
+    /**
+     * The text highlighter to use.
+     */
+    private Highlighter highlighter = null;
+
+    /**
+     * The current edition position on this line.
+     */
+    private int position = 0;
+
+    /**
+     * The current editing position screen column number.
+     */
+    private int screenPosition = 0;
+
+    /**
+     * The raw text of this line, what is passed to Word to determine
+     * highlighting behavior.
+     */
+    private StringBuilder rawText;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Construct a new Line from an existing text string, and highlight
+     * certain strings.
+     *
+     * @param str the text string
+     * @param defaultColor the color for unhighlighted text
+     * @param highlighter the highlighter to use
+     */
+    public Line(final String str, final CellAttributes defaultColor,
+        final Highlighter highlighter) {
+
+        this.defaultColor = defaultColor;
+        this.highlighter = highlighter;
+
+        this.rawText = new StringBuilder();
+        int col = 0;
+        for (int i = 0; i < str.length(); i++) {
+            char ch = str.charAt(i);
+            if (ch == '\t') {
+                // Expand tabs
+                int j = col % 8;
+                do {
+                    rawText.append(' ');
+                    j++;
+                    col++;
+                } while ((j % 8) != 0);
+                continue;
+            }
+            if ((ch <= 0x20) || (ch == 0x7F)) {
+                // Replace all other C0 bytes with CP437 glyphs.
+                rawText.append(GraphicsChars.CP437[(int) ch]);
+                col++;
+                continue;
+            }
+
+            rawText.append(ch);
+            col++;
+        }
+
+        scanLine();
+    }
+
+    /**
+     * Construct a new Line from an existing text string.
+     *
+     * @param str the text string
+     * @param defaultColor the color for unhighlighted text
+     */
+    public Line(final String str, final CellAttributes defaultColor) {
+        this(str, defaultColor, null);
+    }
+
+    /**
+     * Private constructor used by dup().
+     */
+    private Line() {
+        // NOP
+    }
+
+    // ------------------------------------------------------------------------
+    // Line -------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create a duplicate instance.
+     *
+     * @return duplicate intance
+     */
+    public Line dup() {
+        Line other = new Line();
+        other.defaultColor = defaultColor;
+        other.highlighter = highlighter;
+        other.position = position;
+        other.screenPosition = screenPosition;
+        other.rawText = new StringBuilder(rawText);
+        other.scanLine();
+        return other;
+    }
+
+    /**
+     * Get a (shallow) copy of the words in this line.
+     *
+     * @return a copy of the word list
+     */
+    public List<Word> getWords() {
+        return new ArrayList<Word>(words);
+    }
+
+    /**
+     * Get the current cursor position in the text.
+     *
+     * @return the cursor position
+     */
+    public int getRawCursor() {
+        return position;
+    }
+
+    /**
+     * Get the current cursor position on screen.
+     *
+     * @return the cursor position
+     */
+    public int getCursor() {
+        return screenPosition;
+    }
+
+    /**
+     * Set the current cursor position.
+     *
+     * @param cursor the new cursor position
+     */
+    public void setCursor(final int cursor) {
+        if ((cursor < 0)
+            || ((cursor >= getDisplayLength())
+                && (getDisplayLength() > 0))
+        ) {
+            throw new IndexOutOfBoundsException("Max length is " +
+                getDisplayLength() + ", requested position " + cursor);
+        }
+        screenPosition = cursor;
+        position = screenToTextPosition(screenPosition);
+    }
+
+    /**
+     * Get the character at the current cursor position in the text.
+     *
+     * @return the character, or -1 if the cursor is at the end of the line
+     */
+    public int getChar() {
+        if (position == rawText.length()) {
+            return -1;
+        }
+        return rawText.codePointAt(position);
+    }
+
+    /**
+     * Get the on-screen display length.
+     *
+     * @return the number of cells needed to display this line
+     */
+    public int getDisplayLength() {
+        int n = StringUtils.width(rawText.toString());
+
+        if (n > 0) {
+            // If we have any visible characters, add one to the display so
+            // that the position is immediately after the data.
+            return n + 1;
+        }
+        return n;
+    }
+
+    /**
+     * Get the raw string that matches this line.
+     *
+     * @return the string
+     */
+    public String getRawString() {
+        return rawText.toString();
+    }
+
+    /**
+     * Get the raw length of this line.
+     *
+     * @return the length of this line in characters, which may be different
+     * from the number of cells needed to display it
+     */
+    public int length() {
+        return rawText.length();
+    }
+
+    /**
+     * Scan rawText and make words out of it.  Note package private access.
+     */
+    void scanLine() {
+        words.clear();
+        Word word = new Word(this.defaultColor, this.highlighter);
+        words.add(word);
+        for (int i = 0; i < rawText.length();) {
+            int ch = rawText.codePointAt(i);
+            i += Character.charCount(ch);
+            Word newWord = word.addChar(ch);
+            if (newWord != word) {
+                words.add(newWord);
+                word = newWord;
+            }
+        }
+        for (Word w: words) {
+            w.applyHighlight();
+        }
+    }
+
+    /**
+     * Decrement the cursor by one.  If at the first column, do nothing.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean left() {
+        if (position == 0) {
+            return false;
+        }
+        screenPosition -= StringUtils.width(rawText.codePointBefore(position));
+        position -= Character.charCount(rawText.codePointBefore(position));
+        return true;
+    }
+
+    /**
+     * Increment the cursor by one.  If at the last column, do nothing.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean right() {
+        if (getDisplayLength() == 0) {
+            return false;
+        }
+        if (screenPosition == getDisplayLength() - 1) {
+            return false;
+        }
+        if (position < rawText.length()) {
+            screenPosition += StringUtils.width(rawText.codePointAt(position));
+            position += Character.charCount(rawText.codePointAt(position));
+        }
+        assert (position <= rawText.length());
+        return true;
+    }
+
+    /**
+     * Go to the first column of this line.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean home() {
+        if (position > 0) {
+            position = 0;
+            screenPosition = 0;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Go to the last column of this line.
+     *
+     * @return true if the cursor position changed
+     */
+    public boolean end() {
+        if (screenPosition != getDisplayLength() - 1) {
+            position = rawText.length();
+            screenPosition = StringUtils.width(rawText.toString());
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Delete the character under the cursor.
+     */
+    public void del() {
+        assert (words.size() > 0);
+
+        if (screenPosition < getDisplayLength()) {
+            int n = Character.charCount(rawText.codePointAt(position));
+            for (int i = 0; i < n; i++) {
+                rawText.deleteCharAt(position);
+            }
+        }
+
+        // Re-scan the line to determine the new word boundaries.
+        scanLine();
+    }
+
+    /**
+     * Delete the character immediately preceeding the cursor.
+     *
+     * @param tabSize the tab stop size
+     * @param backspaceUnindents If true, backspace at an indent level goes
+     * back a full indent level.  If false, backspace always goes back one
+     * column.
+     */
+    public void backspace(final int tabSize, final boolean backspaceUnindents) {
+        if ((backspaceUnindents == true)
+            && (tabSize > 0)
+            && (screenPosition > 0)
+            && (rawText.charAt(position - 1) == ' ')
+            && ((screenPosition % tabSize) == 0)
+        ) {
+            boolean doBackTab = true;
+            for (int i = 0; i < position; i++) {
+                if (rawText.charAt(i) != ' ') {
+                    doBackTab = false;
+                    break;
+                }
+            }
+            if (doBackTab) {
+                backTab(tabSize);
+                return;
+            }
+        }
+
+        if (left()) {
+            del();
+        }
+    }
+
+    /**
+     * Insert a character at the cursor.
+     *
+     * @param ch the character to insert
+     */
+    public void addChar(final int ch) {
+        if (screenPosition < getDisplayLength() - 1) {
+            rawText.insert(position, Character.toChars(ch));
+        } else {
+            rawText.append(Character.toChars(ch));
+        }
+        position += Character.charCount(ch);
+        screenPosition += StringUtils.width(ch);
+        scanLine();
+    }
+
+    /**
+     * Replace a character at the cursor.
+     *
+     * @param ch the character to replace
+     */
+    public void replaceChar(final int ch) {
+        if (screenPosition < getDisplayLength() - 1) {
+            // Replace character
+            String oldText = rawText.toString();
+            rawText = new StringBuilder(oldText.substring(0, position));
+            rawText.append(Character.toChars(ch));
+            rawText.append(oldText.substring(position + 1));
+            screenPosition += StringUtils.width(rawText.codePointAt(position));
+            position += Character.charCount(ch);
+        } else {
+            rawText.append(Character.toChars(ch));
+            position += Character.charCount(ch);
+            screenPosition += StringUtils.width(ch);
+        }
+        scanLine();
+    }
+
+    /**
+     * Determine string position from screen position.
+     *
+     * @param screenPosition the position on screen
+     * @return the equivalent position in text
+     */
+    private int screenToTextPosition(final int screenPosition) {
+        if (screenPosition == 0) {
+            return 0;
+        }
+
+        int n = 0;
+        for (int i = 0; i < rawText.length(); i++) {
+            n += StringUtils.width(rawText.codePointAt(i));
+            if (n >= screenPosition) {
+                return i + 1;
+            }
+        }
+        // screenPosition exceeds the available text length.
+        throw new IndexOutOfBoundsException("screenPosition " + screenPosition +
+            " exceeds available text length " + rawText.length());
+    }
+
+    /**
+     * Trim trailing whitespace from line, repositioning cursor if needed.
+     */
+    public void trimRight() {
+        if (rawText.length() == 0) {
+            return;
+        }
+        if (!Character.isWhitespace(rawText.charAt(rawText.length() - 1))) {
+            return;
+        }
+        while ((rawText.length() > 0)
+            && Character.isWhitespace(rawText.charAt(rawText.length() - 1))
+        ) {
+            rawText.deleteCharAt(rawText.length() - 1);
+        }
+        if (position >= rawText.length()) {
+            end();
+        }
+        scanLine();
+    }
+
+    /**
+     * Handle the tab character.
+     *
+     * @param tabSize the tab stop size
+     */
+    public void tab(final int tabSize) {
+        if (tabSize > 0) {
+            do {
+                addChar(' ');
+            } while ((screenPosition % tabSize) != 0);
+        }
+    }
+
+    /**
+     * Handle the backtab (shift-tab) character.
+     *
+     * @param tabSize the tab stop size
+     */
+    public void backTab(final int tabSize) {
+        if ((tabSize > 0) && (screenPosition > 0)
+            && (rawText.charAt(position - 1) == ' ')
+        ) {
+            do {
+                backspace(tabSize, false);
+            } while (((screenPosition % tabSize) != 0)
+                && (screenPosition > 0)
+                && (rawText.charAt(position - 1) == ' '));
+        }
+    }
+
+}
diff --git a/src/jexer/teditor/Word.java b/src/jexer/teditor/Word.java
new file mode 100644 (file)
index 0000000..483f9c3
--- /dev/null
@@ -0,0 +1,221 @@
+/*
+ * 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.teditor;
+
+import jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+
+/**
+ * A Word represents text that was entered by the user.  It can be either
+ * whitespace or non-whitespace.
+ *
+ * Very dumb highlighting is supported, it has no sense of parsing (not even
+ * comments).  For now this only highlights some Java keywords and
+ * puctuation.
+ */
+public class Word {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The color to render this word as on screen.
+     */
+    private CellAttributes color = new CellAttributes();
+
+    /**
+     * The default color for the TEditor class.
+     */
+    private CellAttributes defaultColor = null;
+
+    /**
+     * The text highlighter to use.
+     */
+    private Highlighter highlighter = null;
+
+    /**
+     * The actual text of this word.  Average word length is 6 characters,
+     * with a lot of shorter ones, so start with 3.
+     */
+    private StringBuilder text = new StringBuilder(3);
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Construct a word with one character.
+     *
+     * @param ch the first character of the word
+     * @param defaultColor the color for unhighlighted text
+     * @param highlighter the highlighter to use
+     */
+    public Word(final int ch, final CellAttributes defaultColor,
+        final Highlighter highlighter) {
+
+        this.defaultColor = defaultColor;
+        this.highlighter = highlighter;
+        text.append(Character.toChars(ch));
+    }
+
+    /**
+     * Construct a word with an empty string.
+     *
+     * @param defaultColor the color for unhighlighted text
+     * @param highlighter the highlighter to use
+     */
+    public Word(final CellAttributes defaultColor,
+        final Highlighter highlighter) {
+
+        this.defaultColor = defaultColor;
+        this.highlighter = highlighter;
+    }
+
+    // ------------------------------------------------------------------------
+    // Word -------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the color used to display this word on screen.
+     *
+     * @return the color
+     */
+    public CellAttributes getColor() {
+        return new CellAttributes(color);
+    }
+
+    /**
+     * Set the color used to display this word on screen.
+     *
+     * @param color the color
+     */
+    public void setColor(final CellAttributes color) {
+        color.setTo(color);
+    }
+
+    /**
+     * Get the text to display.
+     *
+     * @return the text
+     */
+    public String getText() {
+        return text.toString();
+    }
+
+    /**
+     * Get the on-screen display length.
+     *
+     * @return the number of cells needed to display this word
+     */
+    public int getDisplayLength() {
+        return StringUtils.width(text.toString());
+    }
+
+    /**
+     * See if this is a whitespace word.  Note that empty string is
+     * considered whitespace.
+     *
+     * @return true if this word is whitespace
+     */
+    public boolean isWhitespace() {
+        if (text.length() == 0) {
+            return true;
+        }
+        if (Character.isWhitespace(text.charAt(0))) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Perform highlighting.
+     */
+    public void applyHighlight() {
+        color.setTo(defaultColor);
+        if (highlighter == null) {
+            return;
+        }
+        String key = text.toString();
+        CellAttributes newColor = highlighter.getColor(key);
+        if (newColor != null) {
+            color.setTo(newColor);
+        }
+    }
+
+    /**
+     * Add a character to this word.  If this is a whitespace character
+     * adding to a non-whitespace word, create a new word and return that;
+     * similarly if this a non-whitespace character adding to a whitespace
+     * word, create a new word and return that.  Note package private access:
+     * this is only called by Line to figure out highlighting boundaries.
+     *
+     * @param ch the new character to add
+     * @return either this word (if it was added), or a new word that
+     * contains ch
+     */
+    public Word addChar(final int ch) {
+        if (text.length() == 0) {
+            text.append(Character.toChars(ch));
+            return this;
+        }
+
+        // Give the highlighter the option to split here.
+        if (highlighter != null) {
+            if (highlighter.shouldSplit(ch)
+                || highlighter.shouldSplit(text.charAt(0))
+            ) {
+                Word newWord = new Word(ch, defaultColor, highlighter);
+                return newWord;
+            }
+        }
+
+        // Highlighter didn't care, so split at whitespace.
+        if (Character.isWhitespace(text.charAt(0))
+            && Character.isWhitespace(ch)
+        ) {
+            // Adding to a whitespace word, keep at it.
+            text.append(Character.toChars(ch));
+            return this;
+        }
+        if (!Character.isWhitespace(text.charAt(0))
+            && !Character.isWhitespace(ch)
+        ) {
+            // Adding to a non-whitespace word, keep at it.
+            text.append(Character.toChars(ch));
+            return this;
+        }
+
+        // Switching from whitespace to non-whitespace or vice versa, so
+        // split here.
+        Word newWord = new Word(ch, defaultColor, highlighter);
+        return newWord;
+    }
+
+}
diff --git a/src/jexer/teditor/package-info.java b/src/jexer/teditor/package-info.java
new file mode 100644 (file)
index 0000000..8bf5199
--- /dev/null
@@ -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
+ */
+
+/**
+ * A basic text editor backend supporting word highlighting.
+ */
+package jexer.teditor;
diff --git a/src/jexer/tterminal/DECCharacterSets.java b/src/jexer/tterminal/DECCharacterSets.java
new file mode 100644 (file)
index 0000000..bca81bb
--- /dev/null
@@ -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.tterminal;
+
+/**
+ * This class contains a collection of the DEC VT100 and VT220 character set
+ * mappings into Unicode.
+ */
+public final class DECCharacterSets {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * US - Normal "international" (ASCII).
+     */
+    public static final char [] US_ASCII = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+        0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x0020
+    };
+
+    /**
+     * DEC Supplemental Graphics (VT100 drawing characters).
+     */
+    public static final char [] SPECIAL_GRAPHICS = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+        0x2666, 0x2592, 0x2409, 0x240C, 0x240D, 0x240A, 0x00B0, 0x00B1,
+        0x2424, 0x240B, 0x2518, 0x2510, 0x250C, 0x2514, 0x253C, 0x23BA,
+        0x23BB, 0x2500, 0x23BC, 0x23BD, 0x251C, 0x2524, 0x2534, 0x252C,
+        0x2502, 0x2264, 0x2265, 0x03C0, 0x2260, 0x00A3, 0x00B7, 0x0020
+    };
+
+    /**
+     * Dec Supplemental (DEC multinational).
+     */
+    public static final char [] DEC_SUPPLEMENTAL = {
+        0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
+        0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x008D, 0x008E, 0x008F,
+        0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
+        0x0098, 0x0099, 0x009A, 0x009B, 0x009C, 0x009D, 0x009E, 0x009F,
+        0x0020, 0x00A1, 0x00A2, 0x00A3, 0x00A8, 0x00A5, 0x0020, 0x00A7,
+        0x00A4, 0x00A9, 0x00AA, 0x00AB, 0x0020, 0x0020, 0x0020, 0x0020,
+        0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0020, 0x00B5, 0x00B6, 0x00B7,
+        0x0020, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x0020, 0x00BF,
+        0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
+        0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
+        0x0020, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x0157,
+        0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x0178, 0x0020, 0x00DF,
+        0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
+        0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
+        0x0020, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x0153,
+        0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FF, 0x0020, 0x0020
+    };
+
+    /**
+     * UK.
+     */
+    public static final char [] UK = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+        0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x0020
+    };
+
+    /**
+     * DUTCH.
+     */
+    public static final char [] NL = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00BE, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x0133, 0x00BD, 0x007C, 0x005E, 0x005F,
+        0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00A8, 0x0066, 0x00BC, 0x00B4, 0x0020
+    };
+
+    /**
+     * FINNISH.
+     */
+    public static final char [] FI = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00C5, 0x00DC, 0x005F,
+        0x00E9, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00E5, 0x00FC, 0x0020
+    };
+
+    /**
+     * FRENCH.
+     */
+    public static final char [] FR = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00E0, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00B0, 0x00E7, 0x00A7, 0x005E, 0x005F,
+        0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E9, 0x00F9, 0x00E8, 0x00A8, 0x0020
+    };
+
+    /**
+     * FRENCH_CA.
+     */
+    public static final char [] FR_CA = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00E0, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00E2, 0x00E7, 0x00EA, 0x00EE, 0x005F,
+        0x00F4, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E9, 0x00F9, 0x00E8, 0x00FB, 0x0020
+    };
+
+    /**
+     * GERMAN.
+     */
+    public static final char [] DE = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00A7, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00DC, 0x005E, 0x005F,
+        0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00FC, 0x00DF, 0x0020
+    };
+
+    /**
+     * ITALIAN.
+     */
+    public static final char [] IT = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00A7, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00B0, 0x00E7, 0x00E9, 0x005E, 0x005F,
+        0x00F9, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E0, 0x00F2, 0x00E8, 0x00EC, 0x0020
+    };
+
+    /**
+     * NORWEGIAN.
+     */
+    public static final char [] NO = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00C4, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00C6, 0x00D8, 0x00C5, 0x00DC, 0x005F,
+        0x00E4, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E6, 0x00F8, 0x00E5, 0x00FC, 0x0020
+    };
+
+    /**
+     * SPANISH.
+     */
+    public static final char [] ES = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00A7, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00A1, 0x00D1, 0x00BF, 0x005E, 0x005F,
+        0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00B0, 0x00F1, 0x00E7, 0x007E, 0x0020
+    };
+
+    /**
+     * SWEDISH.
+     */
+    public static final char [] SV = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00C9, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00C5, 0x00DC, 0x005F,
+        0x00E9, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00E5, 0x00FC, 0x0020
+    };
+
+    /**
+     * SWISS.
+     */
+    public static final char [] SWISS = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x00F9, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x00E0, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x00E9, 0x00E7, 0x00EA, 0x00EE, 0x00E8,
+        0x00F4, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+        0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+        0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+        0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00FC, 0x00FB, 0x0020
+    };
+
+    /**
+     * VT52 drawing characters.
+     */
+    public static final char [] VT52_SPECIAL_GRAPHICS = {
+        0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+        0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+        0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+        0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+        0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+        0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+        0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+        0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+        0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+        0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+        0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+        0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x0020, 0x0020,
+        0x0020, 0x2588, 0x215F, 0x2592, 0x2592, 0x2592, 0x00B0, 0x00B1,
+        0x2190, 0x2026, 0x00F7, 0x2193, 0x23BA, 0x23BA, 0x23BB, 0x23BB,
+        0x2500, 0x2500, 0x23BC, 0x23BC, 0x2080, 0x2081, 0x2082, 0x2083,
+        0x2084, 0x2085, 0x2086, 0x2087, 0x2088, 0x2089, 0x00B6, 0x0020
+    };
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Private constructor prevents accidental creation of this class.
+     */
+    private DECCharacterSets() {
+    }
+
+}
diff --git a/src/jexer/tterminal/DisplayLine.java b/src/jexer/tterminal/DisplayLine.java
new file mode 100644 (file)
index 0000000..87e6952
--- /dev/null
@@ -0,0 +1,276 @@
+/*
+ * 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.tterminal;
+
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+
+/**
+ * This represents a single line of the display buffer.
+ */
+public class DisplayLine {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Maximum line length.
+     */
+    private static final int MAX_LINE_LENGTH = 256;
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The characters/attributes of the line.
+     */
+    private Cell [] chars;
+
+    /**
+     * Double-width line flag.
+     */
+    private boolean doubleWidth = false;
+
+    /**
+     * Double height line flag.  Valid values are:
+     *
+     * <p><pre>
+     *   0 = single height
+     *   1 = top half double height
+     *   2 = bottom half double height
+     * </pre>
+     */
+    private int doubleHeight = 0;
+
+    /**
+     * DECSCNM - reverse video.  We copy the flag to the line so that
+     * reverse-mode scrollback lines still show inverted colors correctly.
+     */
+    private boolean reverseColor = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor makes a duplicate (deep copy).
+     *
+     * @param line the line to duplicate
+     */
+    public DisplayLine(final DisplayLine line) {
+        chars = new Cell[MAX_LINE_LENGTH];
+        for (int i = 0; i < chars.length; i++) {
+            chars[i] = new Cell(line.chars[i]);
+        }
+        doubleWidth = line.doubleWidth;
+        doubleHeight = line.doubleHeight;
+        reverseColor = line.reverseColor;
+    }
+
+    /**
+     * Public constructor sets everything to drawing attributes.
+     *
+     * @param attr current drawing attributes
+     */
+    public DisplayLine(final CellAttributes attr) {
+        chars = new Cell[MAX_LINE_LENGTH];
+        for (int i = 0; i < chars.length; i++) {
+            chars[i] = new Cell(attr);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // DisplayLine ------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the Cell at a specific column.
+     *
+     * @param idx the character index
+     * @return the Cell
+     */
+    public Cell charAt(final int idx) {
+        return chars[idx];
+    }
+
+    /**
+     * Get the length of this line.
+     *
+     * @return line length
+     */
+    public int length() {
+        return chars.length;
+    }
+
+    /**
+     * Get double width flag.
+     *
+     * @return double width
+     */
+    public boolean isDoubleWidth() {
+        return doubleWidth;
+    }
+
+    /**
+     * Set double width flag.
+     *
+     * @param doubleWidth new value for double width flag
+     */
+    public void setDoubleWidth(final boolean doubleWidth) {
+        this.doubleWidth = doubleWidth;
+    }
+
+    /**
+     * Get double height flag.
+     *
+     * @return double height
+     */
+    public int getDoubleHeight() {
+        return doubleHeight;
+    }
+
+    /**
+     * Set double height flag.
+     *
+     * @param doubleHeight new value for double height flag
+     */
+    public void setDoubleHeight(final int doubleHeight) {
+        this.doubleHeight = doubleHeight;
+    }
+
+    /**
+     * Get reverse video flag.
+     *
+     * @return reverse video
+     */
+    public boolean isReverseColor() {
+        return reverseColor;
+    }
+
+    /**
+     * Set double-height flag.
+     *
+     * @param reverseColor new value for reverse video flag
+     */
+    public void setReverseColor(final boolean reverseColor) {
+        this.reverseColor = reverseColor;
+    }
+
+    /**
+     * Insert a character at the specified position.
+     *
+     * @param idx the character index
+     * @param newCell the new Cell
+     */
+    public void insert(final int idx, final Cell newCell) {
+        System.arraycopy(chars, idx, chars, idx + 1, chars.length - idx - 1);
+        chars[idx] = new Cell(newCell);
+    }
+
+    /**
+     * Replace character at the specified position.
+     *
+     * @param idx the character index
+     * @param newCell the new Cell
+     */
+    public void replace(final int idx, final Cell newCell) {
+        chars[idx].setTo(newCell);
+    }
+
+    /**
+     * Set the Cell at the specified position to the blank (reset).
+     *
+     * @param idx the character index
+     */
+    public void setBlank(final int idx) {
+        chars[idx].reset();
+    }
+
+    /**
+     * Set the character (just the char, not the attributes) at the specified
+     * position to ch.
+     *
+     * @param idx the character index
+     * @param ch the new char
+     */
+    public void setChar(final int idx, final int ch) {
+        chars[idx].setChar(ch);
+    }
+
+    /**
+     * Set the attributes (just the attributes, not the char) at the
+     * specified position to attr.
+     *
+     * @param idx the character index
+     * @param attr the new attributes
+     */
+    public void setAttr(final int idx, final CellAttributes attr) {
+        chars[idx].setAttr(attr);
+    }
+
+    /**
+     * Delete character at the specified position, filling in the new
+     * character on the right with newCell.
+     *
+     * @param idx the character index
+     * @param newCell the new Cell
+     */
+    public void delete(final int idx, final Cell newCell) {
+        System.arraycopy(chars, idx + 1, chars, idx, chars.length - idx - 1);
+        chars[chars.length - 1] = new Cell(newCell);
+    }
+
+    /**
+     * Determine if line contains image data.
+     *
+     * @return true if the line has image data
+     */
+    public boolean isImage() {
+        for (int i = 0; i < chars.length; i++) {
+            if (chars[i].isImage()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Clear image data from line.
+     */
+    public void clearImages() {
+        for (int i = 0; i < chars.length; i++) {
+            if (chars[i].isImage()) {
+                chars[i].reset();
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/tterminal/DisplayListener.java b/src/jexer/tterminal/DisplayListener.java
new file mode 100644 (file)
index 0000000..d0c9e2d
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * 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.tterminal;
+
+/**
+ * DisplayListener is used to callback into external UI when data has come in
+ * from the remote side.
+ */
+public interface DisplayListener {
+
+    /**
+     * Function to call when the display needs to be updated.
+     */
+    public void displayChanged();
+
+    /**
+     * Function to call to obtain the display width.
+     *
+     * @return the number of columns in the display
+     */
+    public int getDisplayWidth();
+
+    /**
+     * Function to call to obtain the display height.
+     *
+     * @return the number of rows in the display
+     */
+    public int getDisplayHeight();
+
+}
diff --git a/src/jexer/tterminal/ECMA48.java b/src/jexer/tterminal/ECMA48.java
new file mode 100644 (file)
index 0000000..537b2e0
--- /dev/null
@@ -0,0 +1,7675 @@
+/*
+ * 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.tterminal;
+
+import java.awt.Graphics;
+import java.awt.image.BufferedImage;
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayInputStream;
+import java.io.CharArrayWriter;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import javax.imageio.ImageIO;
+
+import jexer.TKeypress;
+import jexer.backend.GlyphMaker;
+import jexer.bits.Color;
+import jexer.bits.Cell;
+import jexer.bits.CellAttributes;
+import jexer.bits.StringUtils;
+import jexer.event.TInputEvent;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.io.ReadTimeoutException;
+import jexer.io.TimeoutInputStream;
+import static jexer.TKeypress.*;
+
+/**
+ * This implements a complex ECMA-48/ISO 6429/ANSI X3.64 type console,
+ * including a scrollback buffer.
+ *
+ * <p>
+ * It currently implements VT100, VT102, VT220, and XTERM with the following
+ * caveats:
+ *
+ * <p>
+ * - The vttest scenario for VT220 8-bit controls (11.1.2.3) reports a
+ *   failure with XTERM.  This is due to vttest failing to decode the UTF-8
+ *   stream.
+ *
+ * <p>
+ * - Smooth scrolling, printing, keyboard locking, keyboard leds, and tests
+ *   from VT100 are not supported.
+ *
+ * <p>
+ * - User-defined keys (DECUDK), downloadable fonts (DECDLD), and VT100/ANSI
+ *   compatibility mode (DECSCL) from VT220 are not supported.  (Also,
+ *   because DECSCL is not supported, it will fail the last part of the
+ *   vttest "Test of VT52 mode" if DeviceType is set to VT220.)
+ *
+ * <p>
+ * - Numeric/application keys from the number pad are not supported because
+ *   they are not exposed from the TKeypress API.
+ *
+ * <p>
+ * - VT52 HOLD SCREEN mode is not supported.
+ *
+ * <p>
+ * - In VT52 graphics mode, the 3/, 5/, and 7/ characters (fraction
+ *   numerators) are not rendered correctly.
+ *
+ * <p>
+ * - All data meant for the 'printer' (CSI Pc ? i) is discarded.
+ */
+public class ECMA48 implements Runnable {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The emulator can emulate several kinds of terminals.
+     */
+    public enum DeviceType {
+        /**
+         * DEC VT100 but also including the three VT102 functions.
+         */
+        VT100,
+
+        /**
+         * DEC VT102.
+         */
+        VT102,
+
+        /**
+         * DEC VT220.
+         */
+        VT220,
+
+        /**
+         * A subset of xterm.
+         */
+        XTERM
+    }
+
+    /**
+     * Parser character scan states.
+     */
+    private enum ScanState {
+        GROUND,
+        ESCAPE,
+        ESCAPE_INTERMEDIATE,
+        CSI_ENTRY,
+        CSI_PARAM,
+        CSI_INTERMEDIATE,
+        CSI_IGNORE,
+        DCS_ENTRY,
+        DCS_INTERMEDIATE,
+        DCS_PARAM,
+        DCS_PASSTHROUGH,
+        DCS_IGNORE,
+        DCS_SIXEL,
+        SOSPMAPC_STRING,
+        OSC_STRING,
+        VT52_DIRECT_CURSOR_ADDRESS
+    }
+
+    /**
+     * The selected number pad mode (DECKPAM, DECKPNM).  We record this, but
+     * can't really use it in keypress() because we do not see number pad
+     * events from TKeypress.
+     */
+    private enum KeypadMode {
+        Application,
+        Numeric
+    }
+
+    /**
+     * Arrow keys can emit three different sequences (DECCKM or VT52
+     * submode).
+     */
+    private enum ArrowKeyMode {
+        VT52,
+        ANSI,
+        VT100
+    }
+
+    /**
+     * Available character sets for GL, GR, G0, G1, G2, G3.
+     */
+    private enum CharacterSet {
+        US,
+        UK,
+        DRAWING,
+        ROM,
+        ROM_SPECIAL,
+        VT52_GRAPHICS,
+        DEC_SUPPLEMENTAL,
+        NRC_DUTCH,
+        NRC_FINNISH,
+        NRC_FRENCH,
+        NRC_FRENCH_CA,
+        NRC_GERMAN,
+        NRC_ITALIAN,
+        NRC_NORWEGIAN,
+        NRC_SPANISH,
+        NRC_SWEDISH,
+        NRC_SWISS
+    }
+
+    /**
+     * Single-shift states used by the C1 control characters SS2 (0x8E) and
+     * SS3 (0x8F).
+     */
+    private enum Singleshift {
+        NONE,
+        SS2,
+        SS3
+    }
+
+    /**
+     * VT220+ lockshift states.
+     */
+    private enum LockshiftMode {
+        NONE,
+        G1_GR,
+        G2_GR,
+        G2_GL,
+        G3_GR,
+        G3_GL
+    }
+
+    /**
+     * XTERM mouse reporting protocols.
+     */
+    public enum MouseProtocol {
+        OFF,
+        X10,
+        NORMAL,
+        BUTTONEVENT,
+        ANYEVENT
+    }
+
+    /**
+     * XTERM mouse reporting encodings.
+     */
+    private enum MouseEncoding {
+        X10,
+        UTF8,
+        SGR
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The enclosing listening object.
+     */
+    private DisplayListener displayListener;
+
+    /**
+     * When true, the reader thread is expected to exit.
+     */
+    private volatile boolean stopReaderThread = false;
+
+    /**
+     * The reader thread.
+     */
+    private Thread readerThread = null;
+
+    /**
+     * The type of emulator to be.
+     */
+    private final DeviceType type;
+
+    /**
+     * The scrollback buffer characters + attributes.
+     */
+    private volatile ArrayList<DisplayLine> scrollback;
+
+    /**
+     * The raw display buffer characters + attributes.
+     */
+    private volatile ArrayList<DisplayLine> display;
+
+    /**
+     * The maximum number of lines in the scrollback buffer.
+     */
+    private int scrollbackMax = 10000;
+
+    /**
+     * The terminal's input.  For type == XTERM, this is an InputStreamReader
+     * with UTF-8 encoding.
+     */
+    private Reader input;
+
+    /**
+     * The terminal's raw InputStream.  This is used for type != XTERM.
+     */
+    private volatile TimeoutInputStream inputStream;
+
+    /**
+     * The terminal's output.  For type == XTERM, this wraps an
+     * OutputStreamWriter with UTF-8 encoding.
+     */
+    private Writer output;
+
+    /**
+     * The terminal's raw OutputStream.  This is used for type != XTERM.
+     */
+    private OutputStream outputStream;
+
+    /**
+     * Current scanning state.
+     */
+    private ScanState scanState;
+
+    /**
+     * Which mouse protocol is active.
+     */
+    private MouseProtocol mouseProtocol = MouseProtocol.OFF;
+
+    /**
+     * Which mouse encoding is active.
+     */
+    private MouseEncoding mouseEncoding = MouseEncoding.X10;
+
+    /**
+     * A terminal may request that the mouse pointer be hidden using a
+     * Privacy Message containing either "hideMousePointer" or
+     * "showMousePointer".  This is currently only used within Jexer by
+     * TTerminalWindow so that only the bottom-most instance of nested
+     * Jexer's draws the mouse within its application window.
+     */
+    private boolean hideMousePointer = false;
+
+    /**
+     * Physical display width.  We start at 80x24, but the user can resize us
+     * bigger/smaller.
+     */
+    private int width = 80;
+
+    /**
+     * Physical display height.  We start at 80x24, but the user can resize
+     * us bigger/smaller.
+     */
+    private int height = 24;
+
+    /**
+     * Top margin of the scrolling region.
+     */
+    private int scrollRegionTop = 0;
+
+    /**
+     * Bottom margin of the scrolling region.
+     */
+    private int scrollRegionBottom = height - 1;
+
+    /**
+     * Right margin column number.  This can be selected by the remote side
+     * to be 80/132 (rightMargin values 79/131), or it can be (width - 1).
+     */
+    private int rightMargin = 79;
+
+    /**
+     * Last character printed.
+     */
+    private int repCh;
+
+    /**
+     * VT100-style line wrapping: a character is placed in column 80 (or
+     * 132), but the line does NOT wrap until another character is written to
+     * column 1 of the next line, after which the cursor moves to column 2.
+     */
+    private boolean wrapLineFlag = false;
+
+    /**
+     * VT220 single shift flag.
+     */
+    private Singleshift singleshift = Singleshift.NONE;
+
+    /**
+     * true = insert characters, false = overwrite.
+     */
+    private boolean insertMode = false;
+
+    /**
+     * VT52 mode as selected by DECANM.  True means VT52, false means
+     * ANSI. Default is ANSI.
+     */
+    private boolean vt52Mode = false;
+
+    /**
+     * Visible cursor (DECTCEM).
+     */
+    private boolean cursorVisible = true;
+
+    /**
+     * Screen title as set by the xterm OSC sequence.  Lots of applications
+     * send a screenTitle regardless of whether it is an xterm client or not.
+     */
+    private String screenTitle = "";
+
+    /**
+     * Parameter characters being collected.
+     */
+    private List<Integer> csiParams;
+
+    /**
+     * Non-csi collect buffer.
+     */
+    private StringBuilder collectBuffer = new StringBuilder(128);
+
+    /**
+     * When true, use the G1 character set.
+     */
+    private boolean shiftOut = false;
+
+    /**
+     * Horizontal tab stop locations.
+     */
+    private List<Integer> tabStops;
+
+    /**
+     * S8C1T.  True means 8bit controls, false means 7bit controls.
+     */
+    private boolean s8c1t = false;
+
+    /**
+     * Printer mode.  True means send all output to printer, which discards
+     * it.
+     */
+    private boolean printerControllerMode = false;
+
+    /**
+     * LMN line mode.  If true, linefeed() puts the cursor on the first
+     * column of the next line.  If false, linefeed() puts the cursor one
+     * line down on the current line.  The default is false.
+     */
+    private boolean newLineMode = false;
+
+    /**
+     * Whether arrow keys send ANSI, VT100, or VT52 sequences.
+     */
+    private ArrowKeyMode arrowKeyMode;
+
+    /**
+     * Whether number pad keys send VT100 or VT52, application or numeric
+     * sequences.
+     */
+    @SuppressWarnings("unused")
+    private KeypadMode keypadMode;
+
+    /**
+     * When true, the terminal is in 132-column mode (DECCOLM).
+     */
+    private boolean columns132 = false;
+
+    /**
+     * true = reverse video.  Set by DECSCNM.
+     */
+    private boolean reverseVideo = false;
+
+    /**
+     * false = echo characters locally.
+     */
+    private boolean fullDuplex = true;
+
+    /**
+     * The current terminal state.
+     */
+    private SaveableState currentState;
+
+    /**
+     * The last saved terminal state.
+     */
+    private SaveableState savedState;
+
+    /**
+     * The 88- or 256-color support RGB colors.
+     */
+    private List<Integer> colors88;
+
+    /**
+     * Sixel collection buffer.
+     */
+    private StringBuilder sixelParseBuffer = new StringBuilder(2048);
+
+    /**
+     * Sixel shared palette.
+     */
+    private HashMap<Integer, java.awt.Color> sixelPalette;
+
+    /**
+     * The width of a character cell in pixels.
+     */
+    private int textWidth = 16;
+
+    /**
+     * The height of a character cell in pixels.
+     */
+    private int textHeight = 20;
+
+    /**
+     * The last used height of a character cell in pixels, only used for
+     * full-width chars.
+     */
+    private int lastTextHeight = -1;
+
+    /**
+     * The glyph drawer for full-width chars.
+     */
+    private GlyphMaker glyphMaker = null;
+
+    /**
+     * Input queue for keystrokes and mouse events to send to the remote
+     * side.
+     */
+    private ArrayList<TInputEvent> userQueue = new ArrayList<TInputEvent>();
+
+    /**
+     * Number of bytes/characters passed to consume().
+     */
+    private long readCount = 0;
+
+    /**
+     * DECSC/DECRC save/restore a subset of the total state.  This class
+     * encapsulates those specific flags/modes.
+     */
+    private class SaveableState {
+
+        /**
+         * When true, cursor positions are relative to the scrolling region.
+         */
+        public boolean originMode = false;
+
+        /**
+         * The current editing X position.
+         */
+        public int cursorX = 0;
+
+        /**
+         * The current editing Y position.
+         */
+        public int cursorY = 0;
+
+        /**
+         * Which character set is currently selected in G0.
+         */
+        public CharacterSet g0Charset = CharacterSet.US;
+
+        /**
+         * Which character set is currently selected in G1.
+         */
+        public CharacterSet g1Charset = CharacterSet.DRAWING;
+
+        /**
+         * Which character set is currently selected in G2.
+         */
+        public CharacterSet g2Charset = CharacterSet.US;
+
+        /**
+         * Which character set is currently selected in G3.
+         */
+        public CharacterSet g3Charset = CharacterSet.US;
+
+        /**
+         * Which character set is currently selected in GR.
+         */
+        public CharacterSet grCharset = CharacterSet.DRAWING;
+
+        /**
+         * The current drawing attributes.
+         */
+        public CellAttributes attr;
+
+        /**
+         * GL lockshift mode.
+         */
+        public LockshiftMode glLockshift = LockshiftMode.NONE;
+
+        /**
+         * GR lockshift mode.
+         */
+        public LockshiftMode grLockshift = LockshiftMode.NONE;
+
+        /**
+         * Line wrap.
+         */
+        public boolean lineWrap = true;
+
+        /**
+         * Reset to defaults.
+         */
+        public void reset() {
+            originMode          = false;
+            cursorX             = 0;
+            cursorY             = 0;
+            g0Charset           = CharacterSet.US;
+            g1Charset           = CharacterSet.DRAWING;
+            g2Charset           = CharacterSet.US;
+            g3Charset           = CharacterSet.US;
+            grCharset           = CharacterSet.DRAWING;
+            attr                = new CellAttributes();
+            glLockshift         = LockshiftMode.NONE;
+            grLockshift         = LockshiftMode.NONE;
+            lineWrap            = true;
+        }
+
+        /**
+         * Copy attributes from another instance.
+         *
+         * @param that the other instance to match
+         */
+        public void setTo(final SaveableState that) {
+            this.originMode     = that.originMode;
+            this.cursorX        = that.cursorX;
+            this.cursorY        = that.cursorY;
+            this.g0Charset      = that.g0Charset;
+            this.g1Charset      = that.g1Charset;
+            this.g2Charset      = that.g2Charset;
+            this.g3Charset      = that.g3Charset;
+            this.grCharset      = that.grCharset;
+            this.attr           = new CellAttributes();
+            this.attr.setTo(that.attr);
+            this.glLockshift    = that.glLockshift;
+            this.grLockshift    = that.grLockshift;
+            this.lineWrap       = that.lineWrap;
+        }
+
+        /**
+         * Public constructor.
+         */
+        public SaveableState() {
+            reset();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param type one of the DeviceType constants to select VT100, VT102,
+     * VT220, or XTERM
+     * @param inputStream an InputStream connected to the remote side.  For
+     * type == XTERM, inputStream is converted to a Reader with UTF-8
+     * encoding.
+     * @param outputStream an OutputStream connected to the remote user.  For
+     * type == XTERM, outputStream is converted to a Writer with UTF-8
+     * encoding.
+     * @param displayListener a callback to the outer display, or null for
+     * default VT100 behavior
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48(final DeviceType type, final InputStream inputStream,
+        final OutputStream outputStream, final DisplayListener displayListener)
+        throws UnsupportedEncodingException {
+
+        assert (inputStream != null);
+        assert (outputStream != null);
+
+        csiParams         = new ArrayList<Integer>();
+        tabStops          = new ArrayList<Integer>();
+        scrollback        = new ArrayList<DisplayLine>();
+        display           = new ArrayList<DisplayLine>();
+
+        this.type         = type;
+        if (inputStream instanceof TimeoutInputStream) {
+            this.inputStream  = (TimeoutInputStream)inputStream;
+        } else {
+            this.inputStream  = new TimeoutInputStream(inputStream, 2000);
+        }
+        if (type == DeviceType.XTERM) {
+            this.input    = new InputStreamReader(new BufferedInputStream(
+                this.inputStream, 1024 * 128), "UTF-8");
+            this.output   = new OutputStreamWriter(new
+                BufferedOutputStream(outputStream), "UTF-8");
+            this.outputStream = null;
+        } else {
+            this.output       = null;
+            this.outputStream = new BufferedOutputStream(outputStream);
+        }
+        this.displayListener  = displayListener;
+
+        reset();
+        for (int i = 0; i < height; i++) {
+            display.add(new DisplayLine(currentState.attr));
+        }
+        assert (currentState.cursorY < height);
+        assert (currentState.cursorX < width);
+
+        // Spin up the input reader
+        readerThread = new Thread(this);
+        readerThread.start();
+    }
+
+    // ------------------------------------------------------------------------
+    // Runnable ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Read function runs on a separate thread.
+     */
+    public final void run() {
+        boolean utf8 = false;
+        boolean done = false;
+
+        if (type == DeviceType.XTERM) {
+            utf8 = true;
+        }
+
+        // available() will often return > 1, so we need to read in chunks to
+        // stay caught up.
+        char [] readBufferUTF8 = null;
+        byte [] readBuffer = null;
+        if (utf8) {
+            readBufferUTF8 = new char[2048];
+        } else {
+            readBuffer = new byte[2048];
+        }
+
+        while (!done && !stopReaderThread) {
+            synchronized (userQueue) {
+                while (userQueue.size() > 0) {
+                    handleUserEvent(userQueue.remove(0));
+                }
+            }
+
+            try {
+                int n = inputStream.available();
+
+                // System.err.printf("available() %d\n", n); System.err.flush();
+                if (utf8) {
+                    if (readBufferUTF8.length < n) {
+                        // The buffer wasn't big enough, make it huger
+                        int newSizeHalf = Math.max(readBufferUTF8.length,
+                            n);
+
+                        readBufferUTF8 = new char[newSizeHalf * 2];
+                    }
+                } else {
+                    if (readBuffer.length < n) {
+                        // The buffer wasn't big enough, make it huger
+                        int newSizeHalf = Math.max(readBuffer.length, n);
+                        readBuffer = new byte[newSizeHalf * 2];
+                    }
+                }
+                if (n == 0) {
+                    try {
+                        Thread.sleep(10);
+                    } catch (InterruptedException e) {
+                        // SQUASH
+                    }
+                    continue;
+                }
+
+                int rc = -1;
+                try {
+                    if (utf8) {
+                        rc = input.read(readBufferUTF8, 0,
+                            readBufferUTF8.length);
+                    } else {
+                        rc = inputStream.read(readBuffer, 0,
+                            readBuffer.length);
+                    }
+                } catch (ReadTimeoutException e) {
+                    rc = 0;
+                }
+
+                // System.err.printf("read() %d\n", rc); System.err.flush();
+                if (rc == -1) {
+                    // This is EOF
+                    done = true;
+                } else {
+                    // Don't step on UI events
+                    synchronized (this) {
+                        if (utf8) {
+                            for (int i = 0; i < rc;) {
+                                int ch = Character.codePointAt(readBufferUTF8,
+                                    i);
+                                i += Character.charCount(ch);
+
+                                // Special case for VT10x: 7-bit characters
+                                // only.
+                                if ((type == DeviceType.VT100)
+                                    || (type == DeviceType.VT102)
+                                ) {
+                                    consume(ch & 0x7F);
+                                } else {
+                                    consume(ch);
+                                }
+                            }
+                        } else {
+                            for (int i = 0; i < rc; i++) {
+                                // Special case for VT10x: 7-bit characters
+                                // only.
+                                if ((type == DeviceType.VT100)
+                                    || (type == DeviceType.VT102)
+                                ) {
+                                    consume(readBuffer[i] & 0x7F);
+                                } else {
+                                    consume(readBuffer[i]);
+                                }
+                            }
+                        }
+                    }
+                    // Permit my enclosing UI to know that I updated.
+                    if (displayListener != null) {
+                        displayListener.displayChanged();
+                    }
+                }
+                // System.err.println("end while loop"); System.err.flush();
+            } catch (IOException e) {
+                done = true;
+
+                // This is an unusual case.  We want to see the stack trace,
+                // but it is related to the spawned process rather than the
+                // actual UI.  We will generate the stack trace, and consume
+                // it as though it was emitted by the shell.
+                CharArrayWriter writer= new CharArrayWriter();
+                // Send a ST and RIS to clear the emulator state.
+                try {
+                    writer.write("\033\\\033c");
+                    writer.write("\n-----------------------------------\n");
+                    e.printStackTrace(new PrintWriter(writer));
+                    writer.write("\n-----------------------------------\n");
+                } catch (IOException e2) {
+                    // SQUASH
+                }
+                char [] stackTrace = writer.toCharArray();
+                for (int i = 0; i < stackTrace.length; i++) {
+                    if (stackTrace[i] == '\n') {
+                        consume('\r');
+                    }
+                    consume(stackTrace[i]);
+                }
+            }
+
+        } // while ((done == false) && (stopReaderThread == false))
+
+        // Let the rest of the world know that I am done.
+        stopReaderThread = true;
+
+        try {
+            inputStream.cancelRead();
+            inputStream.close();
+            inputStream = null;
+        } catch (IOException e) {
+            // SQUASH
+        }
+        try {
+            input.close();
+            input = null;
+        } catch (IOException e) {
+            // SQUASH
+        }
+
+        // Permit my enclosing UI to know that I updated.
+        if (displayListener != null) {
+            displayListener.displayChanged();
+        }
+
+        // System.err.println("*** run() exiting..."); System.err.flush();
+    }
+
+    // ------------------------------------------------------------------------
+    // ECMA48 -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Wait for a period of time to get output from the launched process.
+     *
+     * @param millis millis to wait for, or 0 to wait forever
+     * @return true if the launched process has emitted something
+     */
+    public boolean waitForOutput(final int millis) {
+        if (millis < 0) {
+            throw new IllegalArgumentException("timeout must be >= 0");
+        }
+        int waitedMillis = millis;
+        final int pollTimeout = 5;
+        while (true) {
+            if (readCount != 0) {
+                return true;
+            }
+            if ((millis > 0) && (waitedMillis < 0)){
+                return false;
+            }
+            try {
+                Thread.sleep(pollTimeout);
+            } catch (InterruptedException e) {
+                // SQUASH
+            }
+            waitedMillis -= pollTimeout;
+        }
+    }
+
+    /**
+     * Process keyboard and mouse events from the user.
+     *
+     * @param event the input event to consume
+     */
+    private void handleUserEvent(final TInputEvent event) {
+        if (event instanceof TKeypressEvent) {
+            keypress(((TKeypressEvent) event).getKey());
+        }
+        if (event instanceof TMouseEvent) {
+            mouse((TMouseEvent) event);
+        }
+    }
+
+    /**
+     * Add a keyboard and mouse event from the user to the queue.
+     *
+     * @param event the input event to consume
+     */
+    public void addUserEvent(final TInputEvent event) {
+        synchronized (userQueue) {
+            userQueue.add(event);
+        }
+    }
+
+    /**
+     * Return the proper primary Device Attributes string.
+     *
+     * @return string to send to remote side that is appropriate for the
+     * this.type
+     */
+    private String deviceTypeResponse() {
+        switch (type) {
+        case VT100:
+            // "I am a VT100 with advanced video option" (often VT102)
+            return "\033[?1;2c";
+
+        case VT102:
+            // "I am a VT102"
+            return "\033[?6c";
+
+        case VT220:
+        case XTERM:
+            // "I am a VT220" - 7 bit version, with sixel and Jexer image
+            // support.
+            if (!s8c1t) {
+                return "\033[?62;1;6;9;4;22;444c";
+            }
+            // "I am a VT220" - 8 bit version, with sixel and Jexer image
+            // support.
+            return "\u009b?62;1;6;9;4;22;444c";
+        default:
+            throw new IllegalArgumentException("Invalid device type: " + type);
+        }
+    }
+
+    /**
+     * Return the proper TERM environment variable for this device type.
+     *
+     * @param deviceType DeviceType.VT100, DeviceType, XTERM, etc.
+     * @return "vt100", "xterm", etc.
+     */
+    public static String deviceTypeTerm(final DeviceType deviceType) {
+        switch (deviceType) {
+        case VT100:
+            return "vt100";
+
+        case VT102:
+            return "vt102";
+
+        case VT220:
+            return "vt220";
+
+        case XTERM:
+            return "xterm";
+
+        default:
+            throw new IllegalArgumentException("Invalid device type: "
+                + deviceType);
+        }
+    }
+
+    /**
+     * Return the proper LANG for this device type.  Only XTERM devices know
+     * about UTF-8, the others are defined by their standard to be either
+     * 7-bit or 8-bit characters only.
+     *
+     * @param deviceType DeviceType.VT100, DeviceType, XTERM, etc.
+     * @param baseLang a base language without UTF-8 flag such as "C" or
+     * "en_US"
+     * @return "en_US", "en_US.UTF-8", etc.
+     */
+    public static String deviceTypeLang(final DeviceType deviceType,
+        final String baseLang) {
+
+        switch (deviceType) {
+
+        case VT100:
+        case VT102:
+        case VT220:
+            return baseLang;
+
+        case XTERM:
+            return baseLang + ".UTF-8";
+
+        default:
+            throw new IllegalArgumentException("Invalid device type: "
+                + deviceType);
+        }
+    }
+
+    /**
+     * Write a string directly to the remote side.
+     *
+     * @param str string to send
+     */
+    public void writeRemote(final String str) {
+        if (stopReaderThread) {
+            // Reader hit EOF, bail out now.
+            close();
+            return;
+        }
+
+        // System.err.printf("writeRemote() '%s'\n", str);
+
+        switch (type) {
+        case VT100:
+        case VT102:
+        case VT220:
+            if (outputStream == null) {
+                return;
+            }
+            try {
+                outputStream.flush();
+                for (int i = 0; i < str.length(); i++) {
+                    outputStream.write(str.charAt(i));
+                }
+                outputStream.flush();
+            } catch (IOException e) {
+                // Assume EOF
+                close();
+            }
+            break;
+        case XTERM:
+            if (output == null) {
+                return;
+            }
+            try {
+                output.flush();
+                output.write(str);
+                output.flush();
+            } catch (IOException e) {
+                // Assume EOF
+                close();
+            }
+            break;
+        default:
+            throw new IllegalArgumentException("Invalid device type: " + type);
+        }
+    }
+
+    /**
+     * Close the input and output streams and stop the reader thread.  Note
+     * that it is safe to call this multiple times.
+     */
+    public final void close() {
+
+        // Tell the reader thread to stop looking at input.  It will close
+        // the input streams.
+        if (stopReaderThread == false) {
+            stopReaderThread = true;
+        }
+
+        // Now close the output stream.
+        switch (type) {
+        case VT100:
+        case VT102:
+        case VT220:
+            if (outputStream != null) {
+                try {
+                    outputStream.close();
+                } catch (IOException e) {
+                    // SQUASH
+                }
+                outputStream = null;
+            }
+            break;
+        case XTERM:
+            if (outputStream != null) {
+                try {
+                    outputStream.close();
+                } catch (IOException e) {
+                    // SQUASH
+                }
+                outputStream = null;
+            }
+            if (output != null) {
+                try {
+                    output.close();
+                } catch (IOException e) {
+                    // SQUASH
+                }
+                output = null;
+            }
+            break;
+        default:
+            throw new IllegalArgumentException("Invalid device type: " +
+                type);
+        }
+    }
+
+    /**
+     * See if the reader thread is still running.
+     *
+     * @return if true, we are still connected to / reading from the remote
+     * side
+     */
+    public final boolean isReading() {
+        return (!stopReaderThread);
+    }
+
+    /**
+     * Obtain a new blank display line for an external user
+     * (e.g. TTerminalWindow).
+     *
+     * @return new blank line
+     */
+    public final DisplayLine getBlankDisplayLine() {
+        return new DisplayLine(currentState.attr);
+    }
+
+    /**
+     * Get the scrollback buffer.
+     *
+     * @return the scrollback buffer
+     */
+    public final List<DisplayLine> getScrollbackBuffer() {
+        return scrollback;
+    }
+
+    /**
+     * Get the display buffer.
+     *
+     * @return the display buffer
+     */
+    public final List<DisplayLine> getDisplayBuffer() {
+        return display;
+    }
+
+    /**
+     * Get the visible display + scrollback buffer, offset by a specified
+     * number of rows from the bottom.
+     *
+     * @param visibleHeight the total height of the display to show
+     * @param scrollBottom the number of rows from the bottom to scroll back
+     * @return a copy of the display + scrollback buffers
+     */
+    public final List<DisplayLine> getVisibleDisplay(final int visibleHeight,
+        final int scrollBottom) {
+
+        assert (visibleHeight >= 0);
+        assert (scrollBottom >= 0);
+
+        int visibleBottom = scrollback.size() + display.size() - scrollBottom;
+
+        List<DisplayLine> preceedingBlankLines = new ArrayList<DisplayLine>();
+        int visibleTop = visibleBottom - visibleHeight;
+        if (visibleTop < 0) {
+            for (int i = visibleTop; i < 0; i++) {
+                preceedingBlankLines.add(getBlankDisplayLine());
+            }
+            visibleTop = 0;
+        }
+        assert (visibleTop >= 0);
+
+        List<DisplayLine> displayLines = new ArrayList<DisplayLine>();
+        displayLines.addAll(scrollback);
+        displayLines.addAll(display);
+
+        List<DisplayLine> visibleLines = new ArrayList<DisplayLine>();
+        visibleLines.addAll(preceedingBlankLines);
+        visibleLines.addAll(displayLines.subList(visibleTop, visibleBottom));
+
+        // Fill in the blank lines on bottom
+        int bottomBlankLines = visibleHeight - visibleLines.size();
+        assert (bottomBlankLines >= 0);
+        for (int i = 0; i < bottomBlankLines; i++) {
+            visibleLines.add(getBlankDisplayLine());
+        }
+
+        return copyBuffer(visibleLines);
+    }
+
+    /**
+     * Copy a display buffer.
+     *
+     * @param buffer the buffer to copy
+     * @return a deep copy of the buffer's data
+     */
+    private List<DisplayLine> copyBuffer(final List<DisplayLine> buffer) {
+        ArrayList<DisplayLine> result = new ArrayList<DisplayLine>(buffer.size());
+        for (DisplayLine line: buffer) {
+            result.add(new DisplayLine(line));
+        }
+        return result;
+    }
+
+    /**
+     * Get the display width.
+     *
+     * @return the width (usually 80 or 132)
+     */
+    public final int getWidth() {
+        return width;
+    }
+
+    /**
+     * Set the display width.
+     *
+     * @param width the new width
+     */
+    public final synchronized void setWidth(final int width) {
+        this.width = width;
+        rightMargin = width - 1;
+        if (currentState.cursorX >= width) {
+            currentState.cursorX = width - 1;
+        }
+        if (savedState.cursorX >= width) {
+            savedState.cursorX = width - 1;
+        }
+    }
+
+    /**
+     * Get the display height.
+     *
+     * @return the height (usually 24)
+     */
+    public final int getHeight() {
+        return height;
+    }
+
+    /**
+     * Set the display height.
+     *
+     * @param height the new height
+     */
+    public final synchronized void setHeight(final int height) {
+        int delta = height - this.height;
+        this.height = height;
+        scrollRegionBottom += delta;
+        if ((scrollRegionBottom < 0) || (scrollRegionTop > height - 1)) {
+            scrollRegionBottom = height - 1;
+        }
+        if (scrollRegionTop >= scrollRegionBottom) {
+            scrollRegionTop = 0;
+        }
+        if (currentState.cursorY >= height) {
+            currentState.cursorY = height - 1;
+        }
+        if (savedState.cursorY >= height) {
+            savedState.cursorY = height - 1;
+        }
+        while (display.size() < height) {
+            DisplayLine line = new DisplayLine(currentState.attr);
+            line.setReverseColor(reverseVideo);
+            display.add(line);
+        }
+        while (display.size() > height) {
+            appendScrollbackLine(display.remove(0));
+        }
+    }
+
+    /**
+     * Get the maximum number of lines in the scrollback buffer.
+     *
+     * @return the maximum number of lines in the scrollback buffer
+     */
+    public int getScrollbackMax() {
+        return scrollbackMax;
+    }
+
+    /**
+     * Set the maximum number of lines for the scrollback buffer.
+     *
+     * @param scrollbackMax the maximum number of lines for the scrollback
+     * buffer
+     */
+    public final void setScrollbackMax(final int scrollbackMax) {
+        this.scrollbackMax = scrollbackMax;
+    }
+
+    /**
+     * Get visible cursor flag.
+     *
+     * @return if true, the cursor is visible
+     */
+    public final boolean isCursorVisible() {
+        return cursorVisible;
+    }
+
+    /**
+     * Get the screen title as set by the xterm OSC sequence.  Lots of
+     * applications send a screenTitle regardless of whether it is an xterm
+     * client or not.
+     *
+     * @return screen title
+     */
+    public final String getScreenTitle() {
+        return screenTitle;
+    }
+
+    /**
+     * Get 132 columns value.
+     *
+     * @return if true, the terminal is in 132 column mode
+     */
+    public final boolean isColumns132() {
+        return columns132;
+    }
+
+    /**
+     * Clear the CSI parameters and flags.
+     */
+    private void toGround() {
+        csiParams.clear();
+        collectBuffer.setLength(0);
+        scanState = ScanState.GROUND;
+    }
+
+    /**
+     * Reset the tab stops list.
+     */
+    private void resetTabStops() {
+        tabStops.clear();
+        for (int i = 0; (i * 8) <= rightMargin; i++) {
+            tabStops.add(Integer.valueOf(i * 8));
+        }
+    }
+
+    /**
+     * Reset the 88- or 256-colors.
+     */
+    private void resetColors() {
+        colors88 = new ArrayList<Integer>(256);
+        for (int i = 0; i < 256; i++) {
+            colors88.add(0);
+        }
+
+        // Set default system colors.  These match DOS colors.
+        colors88.set(0, 0x00000000);
+        colors88.set(1, 0x00a80000);
+        colors88.set(2, 0x0000a800);
+        colors88.set(3, 0x00a85400);
+        colors88.set(4, 0x000000a8);
+        colors88.set(5, 0x00a800a8);
+        colors88.set(6, 0x0000a8a8);
+        colors88.set(7, 0x00a8a8a8);
+
+        colors88.set(8, 0x00545454);
+        colors88.set(9, 0x00fc5454);
+        colors88.set(10, 0x0054fc54);
+        colors88.set(11, 0x00fcfc54);
+        colors88.set(12, 0x005454fc);
+        colors88.set(13, 0x00fc54fc);
+        colors88.set(14, 0x0054fcfc);
+        colors88.set(15, 0x00fcfcfc);
+
+        // These match xterm's default colors from 256colres.h.
+        colors88.set(16, 0x000000);
+        colors88.set(17, 0x00005f);
+        colors88.set(18, 0x000087);
+        colors88.set(19, 0x0000af);
+        colors88.set(20, 0x0000d7);
+        colors88.set(21, 0x0000ff);
+        colors88.set(22, 0x005f00);
+        colors88.set(23, 0x005f5f);
+        colors88.set(24, 0x005f87);
+        colors88.set(25, 0x005faf);
+        colors88.set(26, 0x005fd7);
+        colors88.set(27, 0x005fff);
+        colors88.set(28, 0x008700);
+        colors88.set(29, 0x00875f);
+        colors88.set(30, 0x008787);
+        colors88.set(31, 0x0087af);
+        colors88.set(32, 0x0087d7);
+        colors88.set(33, 0x0087ff);
+        colors88.set(34, 0x00af00);
+        colors88.set(35, 0x00af5f);
+        colors88.set(36, 0x00af87);
+        colors88.set(37, 0x00afaf);
+        colors88.set(38, 0x00afd7);
+        colors88.set(39, 0x00afff);
+        colors88.set(40, 0x00d700);
+        colors88.set(41, 0x00d75f);
+        colors88.set(42, 0x00d787);
+        colors88.set(43, 0x00d7af);
+        colors88.set(44, 0x00d7d7);
+        colors88.set(45, 0x00d7ff);
+        colors88.set(46, 0x00ff00);
+        colors88.set(47, 0x00ff5f);
+        colors88.set(48, 0x00ff87);
+        colors88.set(49, 0x00ffaf);
+        colors88.set(50, 0x00ffd7);
+        colors88.set(51, 0x00ffff);
+        colors88.set(52, 0x5f0000);
+        colors88.set(53, 0x5f005f);
+        colors88.set(54, 0x5f0087);
+        colors88.set(55, 0x5f00af);
+        colors88.set(56, 0x5f00d7);
+        colors88.set(57, 0x5f00ff);
+        colors88.set(58, 0x5f5f00);
+        colors88.set(59, 0x5f5f5f);
+        colors88.set(60, 0x5f5f87);
+        colors88.set(61, 0x5f5faf);
+        colors88.set(62, 0x5f5fd7);
+        colors88.set(63, 0x5f5fff);
+        colors88.set(64, 0x5f8700);
+        colors88.set(65, 0x5f875f);
+        colors88.set(66, 0x5f8787);
+        colors88.set(67, 0x5f87af);
+        colors88.set(68, 0x5f87d7);
+        colors88.set(69, 0x5f87ff);
+        colors88.set(70, 0x5faf00);
+        colors88.set(71, 0x5faf5f);
+        colors88.set(72, 0x5faf87);
+        colors88.set(73, 0x5fafaf);
+        colors88.set(74, 0x5fafd7);
+        colors88.set(75, 0x5fafff);
+        colors88.set(76, 0x5fd700);
+        colors88.set(77, 0x5fd75f);
+        colors88.set(78, 0x5fd787);
+        colors88.set(79, 0x5fd7af);
+        colors88.set(80, 0x5fd7d7);
+        colors88.set(81, 0x5fd7ff);
+        colors88.set(82, 0x5fff00);
+        colors88.set(83, 0x5fff5f);
+        colors88.set(84, 0x5fff87);
+        colors88.set(85, 0x5fffaf);
+        colors88.set(86, 0x5fffd7);
+        colors88.set(87, 0x5fffff);
+        colors88.set(88, 0x870000);
+        colors88.set(89, 0x87005f);
+        colors88.set(90, 0x870087);
+        colors88.set(91, 0x8700af);
+        colors88.set(92, 0x8700d7);
+        colors88.set(93, 0x8700ff);
+        colors88.set(94, 0x875f00);
+        colors88.set(95, 0x875f5f);
+        colors88.set(96, 0x875f87);
+        colors88.set(97, 0x875faf);
+        colors88.set(98, 0x875fd7);
+        colors88.set(99, 0x875fff);
+        colors88.set(100, 0x878700);
+        colors88.set(101, 0x87875f);
+        colors88.set(102, 0x878787);
+        colors88.set(103, 0x8787af);
+        colors88.set(104, 0x8787d7);
+        colors88.set(105, 0x8787ff);
+        colors88.set(106, 0x87af00);
+        colors88.set(107, 0x87af5f);
+        colors88.set(108, 0x87af87);
+        colors88.set(109, 0x87afaf);
+        colors88.set(110, 0x87afd7);
+        colors88.set(111, 0x87afff);
+        colors88.set(112, 0x87d700);
+        colors88.set(113, 0x87d75f);
+        colors88.set(114, 0x87d787);
+        colors88.set(115, 0x87d7af);
+        colors88.set(116, 0x87d7d7);
+        colors88.set(117, 0x87d7ff);
+        colors88.set(118, 0x87ff00);
+        colors88.set(119, 0x87ff5f);
+        colors88.set(120, 0x87ff87);
+        colors88.set(121, 0x87ffaf);
+        colors88.set(122, 0x87ffd7);
+        colors88.set(123, 0x87ffff);
+        colors88.set(124, 0xaf0000);
+        colors88.set(125, 0xaf005f);
+        colors88.set(126, 0xaf0087);
+        colors88.set(127, 0xaf00af);
+        colors88.set(128, 0xaf00d7);
+        colors88.set(129, 0xaf00ff);
+        colors88.set(130, 0xaf5f00);
+        colors88.set(131, 0xaf5f5f);
+        colors88.set(132, 0xaf5f87);
+        colors88.set(133, 0xaf5faf);
+        colors88.set(134, 0xaf5fd7);
+        colors88.set(135, 0xaf5fff);
+        colors88.set(136, 0xaf8700);
+        colors88.set(137, 0xaf875f);
+        colors88.set(138, 0xaf8787);
+        colors88.set(139, 0xaf87af);
+        colors88.set(140, 0xaf87d7);
+        colors88.set(141, 0xaf87ff);
+        colors88.set(142, 0xafaf00);
+        colors88.set(143, 0xafaf5f);
+        colors88.set(144, 0xafaf87);
+        colors88.set(145, 0xafafaf);
+        colors88.set(146, 0xafafd7);
+        colors88.set(147, 0xafafff);
+        colors88.set(148, 0xafd700);
+        colors88.set(149, 0xafd75f);
+        colors88.set(150, 0xafd787);
+        colors88.set(151, 0xafd7af);
+        colors88.set(152, 0xafd7d7);
+        colors88.set(153, 0xafd7ff);
+        colors88.set(154, 0xafff00);
+        colors88.set(155, 0xafff5f);
+        colors88.set(156, 0xafff87);
+        colors88.set(157, 0xafffaf);
+        colors88.set(158, 0xafffd7);
+        colors88.set(159, 0xafffff);
+        colors88.set(160, 0xd70000);
+        colors88.set(161, 0xd7005f);
+        colors88.set(162, 0xd70087);
+        colors88.set(163, 0xd700af);
+        colors88.set(164, 0xd700d7);
+        colors88.set(165, 0xd700ff);
+        colors88.set(166, 0xd75f00);
+        colors88.set(167, 0xd75f5f);
+        colors88.set(168, 0xd75f87);
+        colors88.set(169, 0xd75faf);
+        colors88.set(170, 0xd75fd7);
+        colors88.set(171, 0xd75fff);
+        colors88.set(172, 0xd78700);
+        colors88.set(173, 0xd7875f);
+        colors88.set(174, 0xd78787);
+        colors88.set(175, 0xd787af);
+        colors88.set(176, 0xd787d7);
+        colors88.set(177, 0xd787ff);
+        colors88.set(178, 0xd7af00);
+        colors88.set(179, 0xd7af5f);
+        colors88.set(180, 0xd7af87);
+        colors88.set(181, 0xd7afaf);
+        colors88.set(182, 0xd7afd7);
+        colors88.set(183, 0xd7afff);
+        colors88.set(184, 0xd7d700);
+        colors88.set(185, 0xd7d75f);
+        colors88.set(186, 0xd7d787);
+        colors88.set(187, 0xd7d7af);
+        colors88.set(188, 0xd7d7d7);
+        colors88.set(189, 0xd7d7ff);
+        colors88.set(190, 0xd7ff00);
+        colors88.set(191, 0xd7ff5f);
+        colors88.set(192, 0xd7ff87);
+        colors88.set(193, 0xd7ffaf);
+        colors88.set(194, 0xd7ffd7);
+        colors88.set(195, 0xd7ffff);
+        colors88.set(196, 0xff0000);
+        colors88.set(197, 0xff005f);
+        colors88.set(198, 0xff0087);
+        colors88.set(199, 0xff00af);
+        colors88.set(200, 0xff00d7);
+        colors88.set(201, 0xff00ff);
+        colors88.set(202, 0xff5f00);
+        colors88.set(203, 0xff5f5f);
+        colors88.set(204, 0xff5f87);
+        colors88.set(205, 0xff5faf);
+        colors88.set(206, 0xff5fd7);
+        colors88.set(207, 0xff5fff);
+        colors88.set(208, 0xff8700);
+        colors88.set(209, 0xff875f);
+        colors88.set(210, 0xff8787);
+        colors88.set(211, 0xff87af);
+        colors88.set(212, 0xff87d7);
+        colors88.set(213, 0xff87ff);
+        colors88.set(214, 0xffaf00);
+        colors88.set(215, 0xffaf5f);
+        colors88.set(216, 0xffaf87);
+        colors88.set(217, 0xffafaf);
+        colors88.set(218, 0xffafd7);
+        colors88.set(219, 0xffafff);
+        colors88.set(220, 0xffd700);
+        colors88.set(221, 0xffd75f);
+        colors88.set(222, 0xffd787);
+        colors88.set(223, 0xffd7af);
+        colors88.set(224, 0xffd7d7);
+        colors88.set(225, 0xffd7ff);
+        colors88.set(226, 0xffff00);
+        colors88.set(227, 0xffff5f);
+        colors88.set(228, 0xffff87);
+        colors88.set(229, 0xffffaf);
+        colors88.set(230, 0xffffd7);
+        colors88.set(231, 0xffffff);
+        colors88.set(232, 0x080808);
+        colors88.set(233, 0x121212);
+        colors88.set(234, 0x1c1c1c);
+        colors88.set(235, 0x262626);
+        colors88.set(236, 0x303030);
+        colors88.set(237, 0x3a3a3a);
+        colors88.set(238, 0x444444);
+        colors88.set(239, 0x4e4e4e);
+        colors88.set(240, 0x585858);
+        colors88.set(241, 0x626262);
+        colors88.set(242, 0x6c6c6c);
+        colors88.set(243, 0x767676);
+        colors88.set(244, 0x808080);
+        colors88.set(245, 0x8a8a8a);
+        colors88.set(246, 0x949494);
+        colors88.set(247, 0x9e9e9e);
+        colors88.set(248, 0xa8a8a8);
+        colors88.set(249, 0xb2b2b2);
+        colors88.set(250, 0xbcbcbc);
+        colors88.set(251, 0xc6c6c6);
+        colors88.set(252, 0xd0d0d0);
+        colors88.set(253, 0xdadada);
+        colors88.set(254, 0xe4e4e4);
+        colors88.set(255, 0xeeeeee);
+
+    }
+
+    /**
+     * Get the RGB value of one of the indexed colors.
+     *
+     * @param index the color index
+     * @return the RGB value
+     */
+    private int get88Color(final int index) {
+        // System.err.print("get88Color: " + index);
+        if ((index < 0) || (index > colors88.size())) {
+            // System.err.println(" -- UNKNOWN");
+            return 0;
+        }
+        // System.err.printf(" %08x\n", colors88.get(index));
+        return colors88.get(index);
+    }
+
+    /**
+     * Set one of the indexed colors to a color specification.
+     *
+     * @param index the color index
+     * @param spec the specification, typically something like "rgb:aa/bb/cc"
+     */
+    private void set88Color(final int index, final String spec) {
+        // System.err.println("set88Color: " + index + " '" + spec + "'");
+
+        if ((index < 0) || (index > colors88.size())) {
+            return;
+        }
+        if (spec.startsWith("rgb:")) {
+            String [] rgbTokens = spec.substring(4).split("/");
+            if (rgbTokens.length == 3) {
+                try {
+                    int rgb = (Integer.parseInt(rgbTokens[0], 16) << 16);
+                    rgb |= Integer.parseInt(rgbTokens[1], 16) << 8;
+                    rgb |= Integer.parseInt(rgbTokens[2], 16);
+                    // System.err.printf("  set to %08x\n", rgb);
+                    colors88.set(index, rgb);
+                } catch (NumberFormatException e) {
+                    // SQUASH
+                }
+            }
+            return;
+        }
+
+        if (spec.toLowerCase().equals("black")) {
+            colors88.set(index, 0x00000000);
+        } else if (spec.toLowerCase().equals("red")) {
+            colors88.set(index, 0x00a80000);
+        } else if (spec.toLowerCase().equals("green")) {
+            colors88.set(index, 0x0000a800);
+        } else if (spec.toLowerCase().equals("yellow")) {
+            colors88.set(index, 0x00a85400);
+        } else if (spec.toLowerCase().equals("blue")) {
+            colors88.set(index, 0x000000a8);
+        } else if (spec.toLowerCase().equals("magenta")) {
+            colors88.set(index, 0x00a800a8);
+        } else if (spec.toLowerCase().equals("cyan")) {
+            colors88.set(index, 0x0000a8a8);
+        } else if (spec.toLowerCase().equals("white")) {
+            colors88.set(index, 0x00a8a8a8);
+        }
+
+    }
+
+    /**
+     * Reset the emulation state.
+     */
+    private void reset() {
+
+        currentState            = new SaveableState();
+        savedState              = new SaveableState();
+        scanState               = ScanState.GROUND;
+        if (displayListener != null) {
+            width = displayListener.getDisplayWidth();
+            height = displayListener.getDisplayHeight();
+        } else {
+            width               = 80;
+            height              = 24;
+        }
+        scrollRegionTop         = 0;
+        scrollRegionBottom      = height - 1;
+        rightMargin             = width - 1;
+        newLineMode             = false;
+        arrowKeyMode            = ArrowKeyMode.ANSI;
+        keypadMode              = KeypadMode.Numeric;
+        wrapLineFlag            = false;
+
+        // Flags
+        shiftOut                = false;
+        vt52Mode                = false;
+        insertMode              = false;
+        columns132              = false;
+        newLineMode             = false;
+        reverseVideo            = false;
+        fullDuplex              = true;
+        cursorVisible           = true;
+
+        // VT220
+        singleshift             = Singleshift.NONE;
+        s8c1t                   = false;
+        printerControllerMode   = false;
+
+        // XTERM
+        mouseProtocol           = MouseProtocol.OFF;
+        mouseEncoding           = MouseEncoding.X10;
+
+        // Tab stops
+        resetTabStops();
+
+        // Reset extra colors
+        resetColors();
+
+        // Clear CSI stuff
+        toGround();
+    }
+
+    /**
+     * Append a to the scrollback buffer, clearing image data for lines more
+     * than three screenfuls in.
+     */
+    private void appendScrollbackLine(DisplayLine line) {
+        scrollback.add(line);
+        if (scrollback.size() > height * 3) {
+            scrollback.get(scrollback.size() - (height * 3)).clearImages();
+        }
+    }
+
+    /**
+     * Append a new line to the bottom of the display, adding lines off the
+     * top to the scrollback buffer.
+     */
+    private void newDisplayLine() {
+        // Scroll the top line off into the scrollback buffer
+        appendScrollbackLine(display.get(0));
+        while (scrollback.size() > scrollbackMax) {
+            scrollback.remove(0);
+            scrollback.trimToSize();
+        }
+        display.remove(0);
+        display.trimToSize();
+        DisplayLine line = new DisplayLine(currentState.attr);
+        line.setReverseColor(reverseVideo);
+        display.add(line);
+    }
+
+    /**
+     * Wraps the current line.
+     */
+    private void wrapCurrentLine() {
+        if (currentState.cursorY == height - 1) {
+            newDisplayLine();
+        }
+        if (currentState.cursorY < height - 1) {
+            currentState.cursorY++;
+        }
+        currentState.cursorX = 0;
+    }
+
+    /**
+     * Handle a carriage return.
+     */
+    private void carriageReturn() {
+        currentState.cursorX = 0;
+        wrapLineFlag = false;
+    }
+
+    /**
+     * Reverse the color of the visible display.
+     */
+    private void invertDisplayColors() {
+        for (DisplayLine line: display) {
+            line.setReverseColor(!line.isReverseColor());
+        }
+    }
+
+    /**
+     * Handle a linefeed.
+     */
+    private void linefeed() {
+        if (currentState.cursorY < scrollRegionBottom) {
+            // Increment screen y
+            currentState.cursorY++;
+        } else {
+
+            // Screen y does not increment
+
+            /*
+             * Two cases: either we're inside a scrolling region or not.  If
+             * the scrolling region bottom is the bottom of the screen, then
+             * push the top line into the buffer.  Else scroll the scrolling
+             * region up.
+             */
+            if ((scrollRegionBottom == height - 1) && (scrollRegionTop == 0)) {
+
+                // We're at the bottom of the scroll region, AND the scroll
+                // region is the entire screen.
+
+                // New line
+                newDisplayLine();
+
+            } else {
+                // We're at the bottom of the scroll region, AND the scroll
+                // region is NOT the entire screen.
+                scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom, 1);
+            }
+        }
+
+        if (newLineMode) {
+            currentState.cursorX = 0;
+        }
+        wrapLineFlag = false;
+    }
+
+    /**
+     * Prints one character to the display buffer.
+     *
+     * @param ch character to display
+     */
+    private void printCharacter(final int ch) {
+        int rightMargin = this.rightMargin;
+
+        if (StringUtils.width(ch) == 2) {
+            // This is a full-width character.  Save two spaces, and then
+            // draw the character as two image halves.
+            int x0 = currentState.cursorX;
+            int y0 = currentState.cursorY;
+            printCharacter(' ');
+            printCharacter(' ');
+            if ((currentState.cursorX == x0 + 2)
+                && (currentState.cursorY == y0)
+            ) {
+                // We can draw both halves of the character.
+                drawHalves(x0, y0, x0 + 1, y0, ch);
+            } else if ((currentState.cursorX == x0 + 1)
+                && (currentState.cursorY == y0)
+            ) {
+                // VT100 line wrap behavior: we should be at the right
+                // margin.  We can draw both halves of the character.
+                drawHalves(x0, y0, x0 + 1, y0, ch);
+            } else {
+                // The character splits across the line.  Draw the entire
+                // character on the new line, giving one more space for it.
+                x0 = currentState.cursorX - 1;
+                y0 = currentState.cursorY;
+                printCharacter(' ');
+                drawHalves(x0, y0, x0 + 1, y0, ch);
+            }
+            return;
+        }
+
+        // Check if we have double-width, and if so chop at 40/66 instead of
+        // 80/132
+        if (display.get(currentState.cursorY).isDoubleWidth()) {
+            rightMargin = ((rightMargin + 1) / 2) - 1;
+        }
+
+        // Check the unusually-complicated line wrapping conditions...
+        if (currentState.cursorX == rightMargin) {
+
+            if (currentState.lineWrap == true) {
+                /*
+                 * This case happens when: the cursor was already on the
+                 * right margin (either through printing or by an explicit
+                 * placement command), and a character was printed.
+                 *
+                 * The line wraps only when a new character arrives AND the
+                 * cursor is already on the right margin AND has placed a
+                 * character in its cell.  Easier to see than to explain.
+                 */
+                if (wrapLineFlag == false) {
+                    /*
+                     * This block marks the case that we are in the margin
+                     * and the first character has been received and printed.
+                     */
+                    wrapLineFlag = true;
+                } else {
+                    /*
+                     * This block marks the case that we are in the margin
+                     * and the second character has been received and
+                     * printed.
+                     */
+                    wrapLineFlag = false;
+                    wrapCurrentLine();
+                }
+            }
+        } else if (currentState.cursorX <= rightMargin) {
+            /*
+             * This is the normal case: a character came in and was printed
+             * to the left of the right margin column.
+             */
+
+            // Turn off VT100 special-case flag
+            wrapLineFlag = false;
+        }
+
+        // "Print" the character
+        Cell newCell = new Cell(ch);
+        CellAttributes newCellAttributes = (CellAttributes) newCell;
+        newCellAttributes.setTo(currentState.attr);
+        DisplayLine line = display.get(currentState.cursorY);
+
+        if (StringUtils.width(ch) == 1) {
+            // Insert mode special case
+            if (insertMode == true) {
+                line.insert(currentState.cursorX, newCell);
+            } else {
+                // Replace an existing character
+                line.replace(currentState.cursorX, newCell);
+            }
+
+            // Increment horizontal
+            if (wrapLineFlag == false) {
+                currentState.cursorX++;
+                if (currentState.cursorX > rightMargin) {
+                    currentState.cursorX--;
+                }
+            }
+        }
+    }
+
+    /**
+     * Translate the mouse event to a VT100, VT220, or XTERM sequence and
+     * send to the remote side.
+     *
+     * @param mouse mouse event received from the local user
+     */
+    private void mouse(final TMouseEvent mouse) {
+
+        /*
+        System.err.printf("mouse(): protocol %s encoding %s mouse %s\n",
+            mouseProtocol, mouseEncoding, mouse);
+         */
+
+        if (mouseEncoding == MouseEncoding.X10) {
+            // We will support X10 but only for (160,94) and smaller.
+            if ((mouse.getX() >= 160) || (mouse.getY() >= 94)) {
+                return;
+            }
+        }
+
+        switch (mouseProtocol) {
+
+        case OFF:
+            // Do nothing
+            return;
+
+        case X10:
+            // Only report button presses
+            if (mouse.getType() != TMouseEvent.Type.MOUSE_DOWN) {
+                return;
+            }
+            break;
+
+        case NORMAL:
+            // Only report button presses and releases
+            if ((mouse.getType() != TMouseEvent.Type.MOUSE_DOWN)
+                && (mouse.getType() != TMouseEvent.Type.MOUSE_UP)
+            ) {
+                return;
+            }
+            break;
+
+        case BUTTONEVENT:
+            /*
+             * Only report button presses, button releases, and motions that
+             * have a button down (i.e. drag-and-drop).
+             */
+            if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                if (!mouse.isMouse1()
+                    && !mouse.isMouse2()
+                    && !mouse.isMouse3()
+                    && !mouse.isMouseWheelUp()
+                    && !mouse.isMouseWheelDown()
+                ) {
+                    return;
+                }
+            }
+            break;
+
+        case ANYEVENT:
+            // Report everything
+            break;
+        }
+
+        // Now encode the event
+        StringBuilder sb = new StringBuilder(6);
+        if (mouseEncoding == MouseEncoding.SGR) {
+            sb.append((char) 0x1B);
+            sb.append("[<");
+            int buttons = 0;
+
+            if (mouse.isMouse1()) {
+                if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                    buttons = 32;
+                } else {
+                    buttons = 0;
+                }
+            } else if (mouse.isMouse2()) {
+                if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                    buttons = 33;
+                } else {
+                    buttons = 1;
+                }
+            } else if (mouse.isMouse3()) {
+                if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                    buttons = 34;
+                } else {
+                    buttons = 2;
+                }
+            } else if (mouse.isMouseWheelUp()) {
+                buttons = 64;
+            } else if (mouse.isMouseWheelDown()) {
+                buttons = 65;
+            } else {
+                // This is motion with no buttons down.
+                buttons = 35;
+            }
+            if (mouse.isAlt()) {
+                buttons |= 0x08;
+            }
+            if (mouse.isCtrl()) {
+                buttons |= 0x10;
+            }
+            if (mouse.isShift()) {
+                buttons |= 0x04;
+            }
+
+            sb.append(String.format("%d;%d;%d", buttons, mouse.getX() + 1,
+                    mouse.getY() + 1));
+
+            if (mouse.getType() == TMouseEvent.Type.MOUSE_UP) {
+                sb.append("m");
+            } else {
+                sb.append("M");
+            }
+
+        } else {
+            // X10 and UTF8 encodings
+            sb.append((char) 0x1B);
+            sb.append('[');
+            sb.append('M');
+            int buttons = 0;
+            if (mouse.getType() == TMouseEvent.Type.MOUSE_UP) {
+                buttons = 0x03 + 32;
+            } else if (mouse.isMouse1()) {
+                if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                    buttons = 0x00 + 32 + 32;
+                } else {
+                    buttons = 0x00 + 32;
+                }
+            } else if (mouse.isMouse2()) {
+                if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                    buttons = 0x01 + 32 + 32;
+                } else {
+                    buttons = 0x01 + 32;
+                }
+            } else if (mouse.isMouse3()) {
+                if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                    buttons = 0x02 + 32 + 32;
+                } else {
+                    buttons = 0x02 + 32;
+                }
+            } else if (mouse.isMouseWheelUp()) {
+                buttons = 0x04 + 64;
+            } else if (mouse.isMouseWheelDown()) {
+                buttons = 0x05 + 64;
+            } else {
+                // This is motion with no buttons down.
+                buttons = 0x03 + 32;
+            }
+            if (mouse.isAlt()) {
+                buttons |= 0x08;
+            }
+            if (mouse.isCtrl()) {
+                buttons |= 0x10;
+            }
+            if (mouse.isShift()) {
+                buttons |= 0x04;
+            }
+
+            sb.append((char) (buttons & 0xFF));
+            sb.append((char) (mouse.getX() + 33));
+            sb.append((char) (mouse.getY() + 33));
+        }
+
+        // System.err.printf("Would write: \'%s\'\n", sb.toString());
+        writeRemote(sb.toString());
+    }
+
+    /**
+     * Translate the keyboard press to a VT100, VT220, or XTERM sequence and
+     * send to the remote side.
+     *
+     * @param keypress keypress received from the local user
+     */
+    private void keypress(final TKeypress keypress) {
+        writeRemote(keypressToString(keypress));
+    }
+
+    /**
+     * Build one of the complex xterm keystroke sequences, storing the result in
+     * xterm_keystroke_buffer.
+     *
+     * @param ss3 the prefix to use based on VT100 state.
+     * @param first the first character, usually a number.
+     * @param first the last character, one of the following: ~ A B C D F H
+     * @param ctrl whether or not ctrl is down
+     * @param alt whether or not alt is down
+     * @param shift whether or not shift is down
+     * @return the buffer with the full key sequence
+     */
+    private String xtermBuildKeySequence(final String ss3, final char first,
+        final char last, boolean ctrl, boolean alt, boolean shift) {
+
+        StringBuilder sb = new StringBuilder(ss3);
+        if ((last == '~') || (ctrl == true) || (alt == true)
+            || (shift == true)
+        ) {
+            sb.append(first);
+            if (       (ctrl == false) && (alt == false) && (shift == true)) {
+                sb.append(";2");
+            } else if ((ctrl == false) && (alt == true) && (shift == false)) {
+                sb.append(";3");
+            } else if ((ctrl == false) && (alt == true) && (shift == true)) {
+                sb.append(";4");
+            } else if ((ctrl == true) && (alt == false) && (shift == false)) {
+                sb.append(";5");
+            } else if ((ctrl == true) && (alt == false) && (shift == true)) {
+                sb.append(";6");
+            } else if ((ctrl == true) && (alt == true) && (shift == false)) {
+                sb.append(";7");
+            } else if ((ctrl == true) && (alt == true) && (shift == true)) {
+                sb.append(";8");
+            }
+        }
+        sb.append(last);
+        return sb.toString();
+    }
+
+    /**
+     * Translate the keyboard press to a VT100, VT220, or XTERM sequence.
+     *
+     * @param keypress keypress received from the local user
+     * @return string to transmit to the remote side
+     */
+    @SuppressWarnings("fallthrough")
+    private String keypressToString(final TKeypress keypress) {
+
+        if ((fullDuplex == false) && (!keypress.isFnKey())) {
+            /*
+             * If this is a control character, process it like it came from
+             * the remote side.
+             */
+            if (keypress.getChar() < 0x20) {
+                handleControlChar((char) keypress.getChar());
+            } else {
+                // Local echo for everything else
+                printCharacter(keypress.getChar());
+            }
+            if (displayListener != null) {
+                displayListener.displayChanged();
+            }
+        }
+
+        if ((newLineMode == true) && (keypress.equals(kbEnter))) {
+            // NLM: send CRLF
+            return "\015\012";
+        }
+
+        // Handle control characters
+        if ((keypress.isCtrl()) && (!keypress.isFnKey())) {
+            StringBuilder sb = new StringBuilder();
+            int ch = keypress.getChar();
+            ch -= 0x40;
+            sb.append(Character.toChars(ch));
+            return sb.toString();
+        }
+
+        // Handle alt characters
+        if ((keypress.isAlt()) && (!keypress.isFnKey())) {
+            StringBuilder sb = new StringBuilder("\033");
+            int ch = keypress.getChar();
+            sb.append(Character.toChars(ch));
+            return sb.toString();
+        }
+
+        if (keypress.equals(kbBackspaceDel)) {
+            switch (type) {
+            case VT100:
+                return "\010";
+            case VT102:
+                return "\010";
+            case VT220:
+                return "\177";
+            case XTERM:
+                return "\177";
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbLeft)) {
+            switch (type) {
+            case XTERM:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return xtermBuildKeySequence("\033[", '1', 'D',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT52:
+                    return xtermBuildKeySequence("\033", '1', 'D',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT100:
+                    return xtermBuildKeySequence("\033O", '1', 'D',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                }
+            default:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return "\033[D";
+                case VT52:
+                    return "\033D";
+                case VT100:
+                    return "\033OD";
+                }
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbRight)) {
+            switch (type) {
+            case XTERM:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return xtermBuildKeySequence("\033[", '1', 'C',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT52:
+                    return xtermBuildKeySequence("\033", '1', 'C',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT100:
+                    return xtermBuildKeySequence("\033O", '1', 'C',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                }
+            default:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return "\033[C";
+                case VT52:
+                    return "\033C";
+                case VT100:
+                    return "\033OC";
+                }
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbUp)) {
+            switch (type) {
+            case XTERM:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return xtermBuildKeySequence("\033[", '1', 'A',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT52:
+                    return xtermBuildKeySequence("\033", '1', 'A',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT100:
+                    return xtermBuildKeySequence("\033O", '1', 'A',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                }
+            default:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return "\033[A";
+                case VT52:
+                    return "\033A";
+                case VT100:
+                    return "\033OA";
+                }
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbDown)) {
+            switch (type) {
+            case XTERM:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return xtermBuildKeySequence("\033[", '1', 'B',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT52:
+                    return xtermBuildKeySequence("\033", '1', 'B',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT100:
+                    return xtermBuildKeySequence("\033O", '1', 'B',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                }
+            default:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return "\033[B";
+                case VT52:
+                    return "\033B";
+                case VT100:
+                    return "\033OB";
+                }
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbHome)) {
+            switch (type) {
+            case XTERM:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return xtermBuildKeySequence("\033[", '1', 'H',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT52:
+                    return xtermBuildKeySequence("\033", '1', 'H',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT100:
+                    return xtermBuildKeySequence("\033O", '1', 'H',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                }
+            default:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return "\033[H";
+                case VT52:
+                    return "\033H";
+                case VT100:
+                    return "\033OH";
+                }
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbEnd)) {
+            switch (type) {
+            case XTERM:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return xtermBuildKeySequence("\033[", '1', 'F',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT52:
+                    return xtermBuildKeySequence("\033", '1', 'F',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                case VT100:
+                    return xtermBuildKeySequence("\033O", '1', 'F',
+                        keypress.isCtrl(), keypress.isAlt(),
+                        keypress.isShift());
+                }
+            default:
+                switch (arrowKeyMode) {
+                case ANSI:
+                    return "\033[F";
+                case VT52:
+                    return "\033F";
+                case VT100:
+                    return "\033OF";
+                }
+            }
+        }
+
+        if (keypress.equals(kbF1)) {
+            // PF1
+            if (vt52Mode) {
+                return "\033P";
+            }
+            return "\033OP";
+        }
+
+        if (keypress.equals(kbF2)) {
+            // PF2
+            if (vt52Mode) {
+                return "\033Q";
+            }
+            return "\033OQ";
+        }
+
+        if (keypress.equals(kbF3)) {
+            // PF3
+            if (vt52Mode) {
+                return "\033R";
+            }
+            return "\033OR";
+        }
+
+        if (keypress.equals(kbF4)) {
+            // PF4
+            if (vt52Mode) {
+                return "\033S";
+            }
+            return "\033OS";
+        }
+
+        if (keypress.equals(kbF5)) {
+            switch (type) {
+            case VT100:
+                return "\033Ot";
+            case VT102:
+                return "\033Ot";
+            case VT220:
+                return "\033[15~";
+            case XTERM:
+                return "\033[15~";
+            }
+        }
+
+        if (keypress.equals(kbF6)) {
+            switch (type) {
+            case VT100:
+                return "\033Ou";
+            case VT102:
+                return "\033Ou";
+            case VT220:
+                return "\033[17~";
+            case XTERM:
+                return "\033[17~";
+            }
+        }
+
+        if (keypress.equals(kbF7)) {
+            switch (type) {
+            case VT100:
+                return "\033Ov";
+            case VT102:
+                return "\033Ov";
+            case VT220:
+                return "\033[18~";
+            case XTERM:
+                return "\033[18~";
+            }
+        }
+
+        if (keypress.equals(kbF8)) {
+            switch (type) {
+            case VT100:
+                return "\033Ol";
+            case VT102:
+                return "\033Ol";
+            case VT220:
+                return "\033[19~";
+            case XTERM:
+                return "\033[19~";
+            }
+        }
+
+        if (keypress.equals(kbF9)) {
+            switch (type) {
+            case VT100:
+                return "\033Ow";
+            case VT102:
+                return "\033Ow";
+            case VT220:
+                return "\033[20~";
+            case XTERM:
+                return "\033[20~";
+            }
+        }
+
+        if (keypress.equals(kbF10)) {
+            switch (type) {
+            case VT100:
+                return "\033Ox";
+            case VT102:
+                return "\033Ox";
+            case VT220:
+                return "\033[21~";
+            case XTERM:
+                return "\033[21~";
+            }
+        }
+
+        if (keypress.equals(kbF11)) {
+            return "\033[23~";
+        }
+
+        if (keypress.equals(kbF12)) {
+            return "\033[24~";
+        }
+
+        if (keypress.equals(kbShiftF1)) {
+            // Shifted PF1
+            if (vt52Mode) {
+                return "\0332P";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;2P";
+            }
+            return "\033O2P";
+        }
+
+        if (keypress.equals(kbShiftF2)) {
+            // Shifted PF2
+            if (vt52Mode) {
+                return "\0332Q";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;2Q";
+            }
+            return "\033O2Q";
+        }
+
+        if (keypress.equals(kbShiftF3)) {
+            // Shifted PF3
+            if (vt52Mode) {
+                return "\0332R";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;2R";
+            }
+            return "\033O2R";
+        }
+
+        if (keypress.equals(kbShiftF4)) {
+            // Shifted PF4
+            if (vt52Mode) {
+                return "\0332S";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;2S";
+            }
+            return "\033O2S";
+        }
+
+        if (keypress.equals(kbShiftF5)) {
+            // Shifted F5
+            return "\033[15;2~";
+        }
+
+        if (keypress.equals(kbShiftF6)) {
+            // Shifted F6
+            return "\033[17;2~";
+        }
+
+        if (keypress.equals(kbShiftF7)) {
+            // Shifted F7
+            return "\033[18;2~";
+        }
+
+        if (keypress.equals(kbShiftF8)) {
+            // Shifted F8
+            return "\033[19;2~";
+        }
+
+        if (keypress.equals(kbShiftF9)) {
+            // Shifted F9
+            return "\033[20;2~";
+        }
+
+        if (keypress.equals(kbShiftF10)) {
+            // Shifted F10
+            return "\033[21;2~";
+        }
+
+        if (keypress.equals(kbShiftF11)) {
+            // Shifted F11
+            return "\033[23;2~";
+        }
+
+        if (keypress.equals(kbShiftF12)) {
+            // Shifted F12
+            return "\033[24;2~";
+        }
+
+        if (keypress.equals(kbCtrlF1)) {
+            // Control PF1
+            if (vt52Mode) {
+                return "\0335P";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;5P";
+            }
+            return "\033O5P";
+        }
+
+        if (keypress.equals(kbCtrlF2)) {
+            // Control PF2
+            if (vt52Mode) {
+                return "\0335Q";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;5Q";
+            }
+            return "\033O5Q";
+        }
+
+        if (keypress.equals(kbCtrlF3)) {
+            // Control PF3
+            if (vt52Mode) {
+                return "\0335R";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;5R";
+            }
+            return "\033O5R";
+        }
+
+        if (keypress.equals(kbCtrlF4)) {
+            // Control PF4
+            if (vt52Mode) {
+                return "\0335S";
+            }
+            if (type == DeviceType.XTERM) {
+                return "\0331;5S";
+            }
+            return "\033O5S";
+        }
+
+        if (keypress.equals(kbCtrlF5)) {
+            // Control F5
+            return "\033[15;5~";
+        }
+
+        if (keypress.equals(kbCtrlF6)) {
+            // Control F6
+            return "\033[17;5~";
+        }
+
+        if (keypress.equals(kbCtrlF7)) {
+            // Control F7
+            return "\033[18;5~";
+        }
+
+        if (keypress.equals(kbCtrlF8)) {
+            // Control F8
+            return "\033[19;5~";
+        }
+
+        if (keypress.equals(kbCtrlF9)) {
+            // Control F9
+            return "\033[20;5~";
+        }
+
+        if (keypress.equals(kbCtrlF10)) {
+            // Control F10
+            return "\033[21;5~";
+        }
+
+        if (keypress.equals(kbCtrlF11)) {
+            // Control F11
+            return "\033[23;5~";
+        }
+
+        if (keypress.equals(kbCtrlF12)) {
+            // Control F12
+            return "\033[24;5~";
+        }
+
+        if (keypress.equalsWithoutModifiers(kbPgUp)) {
+            switch (type) {
+            case XTERM:
+                return xtermBuildKeySequence("\033[", '5', '~',
+                    keypress.isCtrl(), keypress.isAlt(),
+                    keypress.isShift());
+            default:
+                return "\033[5~";
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbPgDn)) {
+            switch (type) {
+            case XTERM:
+                return xtermBuildKeySequence("\033[", '6', '~',
+                    keypress.isCtrl(), keypress.isAlt(),
+                    keypress.isShift());
+            default:
+                return "\033[6~";
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbIns)) {
+            switch (type) {
+            case XTERM:
+                return xtermBuildKeySequence("\033[", '2', '~',
+                    keypress.isCtrl(), keypress.isAlt(),
+                    keypress.isShift());
+            default:
+                return "\033[2~";
+            }
+        }
+
+        if (keypress.equalsWithoutModifiers(kbDel)) {
+            switch (type) {
+            case XTERM:
+                return xtermBuildKeySequence("\033[", '3', '~',
+                    keypress.isCtrl(), keypress.isAlt(),
+                    keypress.isShift());
+            default:
+                // Delete sends real delete for VTxxx
+                return "\177";
+            }
+        }
+
+        if (keypress.equals(kbEnter)) {
+            return "\015";
+        }
+
+        if (keypress.equals(kbEsc)) {
+            return "\033";
+        }
+
+        if (keypress.equals(kbAltEsc)) {
+            return "\033\033";
+        }
+
+        if (keypress.equals(kbTab)) {
+            return "\011";
+        }
+
+        if ((keypress.equalsWithoutModifiers(kbBackTab)) ||
+            (keypress.equals(kbShiftTab))
+        ) {
+            switch (type) {
+            case XTERM:
+                return "\033[Z";
+            default:
+                return "\011";
+            }
+        }
+
+        // Non-alt, non-ctrl characters
+        if (!keypress.isFnKey()) {
+            StringBuilder sb = new StringBuilder();
+            sb.append(Character.toChars(keypress.getChar()));
+            return sb.toString();
+        }
+        return "";
+    }
+
+    /**
+     * Map a symbol in any one of the VT100/VT220 character sets to a Unicode
+     * symbol.
+     *
+     * @param ch 8-bit character from the remote side
+     * @param charsetGl character set defined for GL
+     * @param charsetGr character set defined for GR
+     * @return character to display on the screen
+     */
+    private char mapCharacterCharset(final int ch,
+        final CharacterSet charsetGl,
+        final CharacterSet charsetGr) {
+
+        int lookupChar = ch;
+        CharacterSet lookupCharset = charsetGl;
+
+        if (ch >= 0x80) {
+            assert ((type == DeviceType.VT220) || (type == DeviceType.XTERM));
+            lookupCharset = charsetGr;
+            lookupChar &= 0x7F;
+        }
+
+        switch (lookupCharset) {
+
+        case DRAWING:
+            return DECCharacterSets.SPECIAL_GRAPHICS[lookupChar];
+
+        case UK:
+            return DECCharacterSets.UK[lookupChar];
+
+        case US:
+            return DECCharacterSets.US_ASCII[lookupChar];
+
+        case NRC_DUTCH:
+            return DECCharacterSets.NL[lookupChar];
+
+        case NRC_FINNISH:
+            return DECCharacterSets.FI[lookupChar];
+
+        case NRC_FRENCH:
+            return DECCharacterSets.FR[lookupChar];
+
+        case NRC_FRENCH_CA:
+            return DECCharacterSets.FR_CA[lookupChar];
+
+        case NRC_GERMAN:
+            return DECCharacterSets.DE[lookupChar];
+
+        case NRC_ITALIAN:
+            return DECCharacterSets.IT[lookupChar];
+
+        case NRC_NORWEGIAN:
+            return DECCharacterSets.NO[lookupChar];
+
+        case NRC_SPANISH:
+            return DECCharacterSets.ES[lookupChar];
+
+        case NRC_SWEDISH:
+            return DECCharacterSets.SV[lookupChar];
+
+        case NRC_SWISS:
+            return DECCharacterSets.SWISS[lookupChar];
+
+        case DEC_SUPPLEMENTAL:
+            return DECCharacterSets.DEC_SUPPLEMENTAL[lookupChar];
+
+        case VT52_GRAPHICS:
+            return DECCharacterSets.VT52_SPECIAL_GRAPHICS[lookupChar];
+
+        case ROM:
+            return DECCharacterSets.US_ASCII[lookupChar];
+
+        case ROM_SPECIAL:
+            return DECCharacterSets.US_ASCII[lookupChar];
+
+        default:
+            throw new IllegalArgumentException("Invalid character set value: "
+                + lookupCharset);
+        }
+    }
+
+    /**
+     * Map an 8-bit byte into a printable character.
+     *
+     * @param ch either 8-bit or Unicode character from the remote side
+     * @return character to display on the screen
+     */
+    private int mapCharacter(final int ch) {
+        if (ch >= 0x100) {
+            // Unicode character, just return it
+            return ch;
+        }
+
+        CharacterSet charsetGl = currentState.g0Charset;
+        CharacterSet charsetGr = currentState.grCharset;
+
+        if (vt52Mode == true) {
+            if (shiftOut == true) {
+                // Shifted out character, pull from VT52 graphics
+                charsetGl = currentState.g1Charset;
+                charsetGr = CharacterSet.US;
+            } else {
+                // Normal
+                charsetGl = currentState.g0Charset;
+                charsetGr = CharacterSet.US;
+            }
+
+            // Pull the character
+            return mapCharacterCharset(ch, charsetGl, charsetGr);
+        }
+
+        // shiftOout
+        if (shiftOut == true) {
+            // Shifted out character, pull from G1
+            charsetGl = currentState.g1Charset;
+            charsetGr = currentState.grCharset;
+
+            // Pull the character
+            return mapCharacterCharset(ch, charsetGl, charsetGr);
+        }
+
+        // SS2
+        if (singleshift == Singleshift.SS2) {
+
+            singleshift = Singleshift.NONE;
+
+            // Shifted out character, pull from G2
+            charsetGl = currentState.g2Charset;
+            charsetGr = currentState.grCharset;
+        }
+
+        // SS3
+        if (singleshift == Singleshift.SS3) {
+
+            singleshift = Singleshift.NONE;
+
+            // Shifted out character, pull from G3
+            charsetGl = currentState.g3Charset;
+            charsetGr = currentState.grCharset;
+        }
+
+        if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+            // Check for locking shift
+
+            switch (currentState.glLockshift) {
+
+            case G1_GR:
+                throw new IllegalArgumentException("programming bug");
+
+            case G2_GR:
+                throw new IllegalArgumentException("programming bug");
+
+            case G3_GR:
+                throw new IllegalArgumentException("programming bug");
+
+            case G2_GL:
+                // LS2
+                charsetGl = currentState.g2Charset;
+                break;
+
+            case G3_GL:
+                // LS3
+                charsetGl = currentState.g3Charset;
+                break;
+
+            case NONE:
+                // Normal
+                charsetGl = currentState.g0Charset;
+                break;
+            }
+
+            switch (currentState.grLockshift) {
+
+            case G2_GL:
+                throw new IllegalArgumentException("programming bug");
+
+            case G3_GL:
+                throw new IllegalArgumentException("programming bug");
+
+            case G1_GR:
+                // LS1R
+                charsetGr = currentState.g1Charset;
+                break;
+
+            case G2_GR:
+                // LS2R
+                charsetGr = currentState.g2Charset;
+                break;
+
+            case G3_GR:
+                // LS3R
+                charsetGr = currentState.g3Charset;
+                break;
+
+            case NONE:
+                // Normal
+                charsetGr = CharacterSet.DEC_SUPPLEMENTAL;
+                break;
+            }
+
+
+        }
+
+        // Pull the character
+        return mapCharacterCharset(ch, charsetGl, charsetGr);
+    }
+
+    /**
+     * Scroll the text within a scrolling region up n lines.
+     *
+     * @param regionTop top row of the scrolling region
+     * @param regionBottom bottom row of the scrolling region
+     * @param n number of lines to scroll
+     */
+    private void scrollingRegionScrollUp(final int regionTop,
+        final int regionBottom, final int n) {
+
+        if (regionTop >= regionBottom) {
+            return;
+        }
+
+        // Sanity check: see if there will be any characters left after the
+        // scroll
+        if (regionBottom + 1 - regionTop <= n) {
+            // There won't be anything left in the region, so just call
+            // eraseScreen() and return.
+            eraseScreen(regionTop, 0, regionBottom, width - 1, false);
+            return;
+        }
+
+        int remaining = regionBottom + 1 - regionTop - n;
+        List<DisplayLine> displayTop = display.subList(0, regionTop);
+        List<DisplayLine> displayBottom = display.subList(regionBottom + 1,
+            display.size());
+        List<DisplayLine> displayMiddle = display.subList(regionBottom + 1
+            - remaining, regionBottom + 1);
+        display = new ArrayList<DisplayLine>(displayTop);
+        display.addAll(displayMiddle);
+        for (int i = 0; i < n; i++) {
+            DisplayLine line = new DisplayLine(currentState.attr);
+            line.setReverseColor(reverseVideo);
+            display.add(line);
+        }
+        display.addAll(displayBottom);
+
+        assert (display.size() == height);
+    }
+
+    /**
+     * Scroll the text within a scrolling region down n lines.
+     *
+     * @param regionTop top row of the scrolling region
+     * @param regionBottom bottom row of the scrolling region
+     * @param n number of lines to scroll
+     */
+    private void scrollingRegionScrollDown(final int regionTop,
+        final int regionBottom, final int n) {
+
+        if (regionTop >= regionBottom) {
+            return;
+        }
+
+        // Sanity check: see if there will be any characters left after the
+        // scroll
+        if (regionBottom + 1 - regionTop <= n) {
+            // There won't be anything left in the region, so just call
+            // eraseScreen() and return.
+            eraseScreen(regionTop, 0, regionBottom, width - 1, false);
+            return;
+        }
+
+        int remaining = regionBottom + 1 - regionTop - n;
+        List<DisplayLine> displayTop = display.subList(0, regionTop);
+        List<DisplayLine> displayBottom = display.subList(regionBottom + 1,
+            display.size());
+        List<DisplayLine> displayMiddle = display.subList(regionTop,
+            regionTop + remaining);
+        display = new ArrayList<DisplayLine>(displayTop);
+        for (int i = 0; i < n; i++) {
+            DisplayLine line = new DisplayLine(currentState.attr);
+            line.setReverseColor(reverseVideo);
+            display.add(line);
+        }
+        display.addAll(displayMiddle);
+        display.addAll(displayBottom);
+
+        assert (display.size() == height);
+    }
+
+    /**
+     * Process a control character.
+     *
+     * @param ch 8-bit character from the remote side
+     */
+    private void handleControlChar(final char ch) {
+        assert ((ch <= 0x1F) || ((ch >= 0x7F) && (ch <= 0x9F)));
+
+        switch (ch) {
+
+        case 0x00:
+            // NUL - discard
+            return;
+
+        case 0x05:
+            // ENQ
+
+            // Transmit the answerback message.
+            // Not supported
+            break;
+
+        case 0x07:
+            // BEL
+            // Not supported
+            break;
+
+        case 0x08:
+            // BS
+            cursorLeft(1, false);
+            break;
+
+        case 0x09:
+            // HT
+            advanceToNextTabStop();
+            break;
+
+        case 0x0A:
+            // LF
+            linefeed();
+            break;
+
+        case 0x0B:
+            // VT
+            linefeed();
+            break;
+
+        case 0x0C:
+            // FF
+            linefeed();
+            break;
+
+        case 0x0D:
+            // CR
+            carriageReturn();
+            break;
+
+        case 0x0E:
+            // SO
+            shiftOut = true;
+            currentState.glLockshift = LockshiftMode.NONE;
+            break;
+
+        case 0x0F:
+            // SI
+            shiftOut = false;
+            currentState.glLockshift = LockshiftMode.NONE;
+            break;
+
+        case 0x84:
+            // IND
+            ind();
+            break;
+
+        case 0x85:
+            // NEL
+            nel();
+            break;
+
+        case 0x88:
+            // HTS
+            hts();
+            break;
+
+        case 0x8D:
+            // RI
+            ri();
+            break;
+
+        case 0x8E:
+            // SS2
+            singleshift = Singleshift.SS2;
+            break;
+
+        case 0x8F:
+            // SS3
+            singleshift = Singleshift.SS3;
+            break;
+
+        default:
+            break;
+        }
+
+    }
+
+    /**
+     * Advance the cursor to the next tab stop.
+     */
+    private void advanceToNextTabStop() {
+        if (tabStops.size() == 0) {
+            // Go to the rightmost column
+            cursorRight(rightMargin - currentState.cursorX, false);
+            return;
+        }
+        for (Integer stop: tabStops) {
+            if (stop > currentState.cursorX) {
+                cursorRight(stop - currentState.cursorX, false);
+                return;
+            }
+        }
+        /*
+         * We got here, meaning there isn't a tab stop beyond the current
+         * cursor position.  Place the cursor of the right-most edge of the
+         * screen.
+         */
+        cursorRight(rightMargin - currentState.cursorX, false);
+    }
+
+    /**
+     * Save a character into the collect buffer.
+     *
+     * @param ch character to save
+     */
+    private void collect(final char ch) {
+        collectBuffer.append(ch);
+    }
+
+    /**
+     * Save a byte into the CSI parameters buffer.
+     *
+     * @param ch byte to save
+     */
+    private void param(final byte ch) {
+        if (csiParams.size() == 0) {
+            csiParams.add(Integer.valueOf(0));
+        }
+        Integer x = csiParams.get(csiParams.size() - 1);
+        if ((ch >= '0') && (ch <= '9')) {
+            x *= 10;
+            x += (ch - '0');
+            csiParams.set(csiParams.size() - 1, x);
+        }
+
+        if ((ch == ';') && (csiParams.size() < 16)) {
+            csiParams.add(Integer.valueOf(0));
+        }
+    }
+
+    /**
+     * Get a CSI parameter value, with a default.
+     *
+     * @param position parameter index.  0 is the first parameter.
+     * @param defaultValue value to use if csiParams[position] doesn't exist
+     * @return parameter value
+     */
+    private int getCsiParam(final int position, final int defaultValue) {
+        if (csiParams.size() < position + 1) {
+            return defaultValue;
+        }
+        return csiParams.get(position).intValue();
+    }
+
+    /**
+     * Get a CSI parameter value, clamped to within min/max.
+     *
+     * @param position parameter index.  0 is the first parameter.
+     * @param defaultValue value to use if csiParams[position] doesn't exist
+     * @param minValue minimum value inclusive
+     * @param maxValue maximum value inclusive
+     * @return parameter value
+     */
+    private int getCsiParam(final int position, final int defaultValue,
+        final int minValue, final int maxValue) {
+
+        assert (minValue <= maxValue);
+        int value = getCsiParam(position, defaultValue);
+        if (value < minValue) {
+            value = minValue;
+        }
+        if (value > maxValue) {
+            value = maxValue;
+        }
+        return value;
+    }
+
+    /**
+     * Set or unset a toggle.
+     *
+     * @param value true for set ('h'), false for reset ('l')
+     */
+    private void setToggle(final boolean value) {
+        boolean decPrivateModeFlag = false;
+
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                decPrivateModeFlag = true;
+                break;
+            }
+        }
+
+        for (Integer i: csiParams) {
+
+            switch (i) {
+
+            case 1:
+                if (decPrivateModeFlag == true) {
+                    // DECCKM
+                    if (value == true) {
+                        // Use application arrow keys
+                        arrowKeyMode = ArrowKeyMode.VT100;
+                    } else {
+                        // Use ANSI arrow keys
+                        arrowKeyMode = ArrowKeyMode.ANSI;
+                    }
+                }
+                break;
+            case 2:
+                if (decPrivateModeFlag == true) {
+                    if (value == false) {
+
+                        // DECANM
+                        vt52Mode = true;
+                        arrowKeyMode = ArrowKeyMode.VT52;
+
+                        /*
+                         * From the VT102 docs: "You use ANSI mode to select
+                         * most terminal features; the terminal uses the same
+                         * features when it switches to VT52 mode. You
+                         * cannot, however, change most of these features in
+                         * VT52 mode."
+                         *
+                         * In other words, do not reset any other attributes
+                         * when switching between VT52 submode and ANSI.
+                         *
+                         * HOWEVER, the real vt100 does switch the character
+                         * set according to Usenet.
+                         */
+                        currentState.g0Charset = CharacterSet.US;
+                        currentState.g1Charset = CharacterSet.DRAWING;
+                        shiftOut = false;
+
+                        if ((type == DeviceType.VT220)
+                            || (type == DeviceType.XTERM)) {
+
+                            // VT52 mode is explicitly 7-bit
+                            s8c1t = false;
+                            singleshift = Singleshift.NONE;
+                        }
+                    }
+                } else {
+                    // KAM
+                    if (value == true) {
+                        // Turn off keyboard
+                        // Not supported
+                    } else {
+                        // Turn on keyboard
+                        // Not supported
+                    }
+                }
+                break;
+            case 3:
+                if (decPrivateModeFlag == true) {
+                    // DECCOLM
+                    if (value == true) {
+                        // 132 columns
+                        columns132 = true;
+                        rightMargin = 131;
+                    } else {
+                        // 80 columns
+                        columns132 = false;
+                        if ((displayListener != null)
+                            && (type == DeviceType.XTERM)
+                        ) {
+                            // For xterms, reset to the actual width, not 80
+                            // columns.
+                            width = displayListener.getDisplayWidth();
+                            rightMargin = width - 1;
+                        } else {
+                            rightMargin = 79;
+                            width = rightMargin + 1;
+                        }
+                    }
+                    // Entire screen is cleared, and scrolling region is
+                    // reset
+                    eraseScreen(0, 0, height - 1, width - 1, false);
+                    scrollRegionTop = 0;
+                    scrollRegionBottom = height - 1;
+                    // Also home the cursor
+                    cursorPosition(0, 0);
+                }
+                break;
+            case 4:
+                if (decPrivateModeFlag == true) {
+                    // DECSCLM
+                    if (value == true) {
+                        // Smooth scroll
+                        // Not supported
+                    } else {
+                        // Jump scroll
+                        // Not supported
+                    }
+                } else {
+                    // IRM
+                    if (value == true) {
+                        insertMode = true;
+                    } else {
+                        insertMode = false;
+                    }
+                }
+                break;
+            case 5:
+                if (decPrivateModeFlag == true) {
+                    // DECSCNM
+                    if (value == true) {
+                        /*
+                         * Set selects reverse screen, a white screen
+                         * background with black characters.
+                         */
+                        if (reverseVideo != true) {
+                            /*
+                             * If in normal video, switch it back
+                             */
+                            invertDisplayColors();
+                        }
+                        reverseVideo = true;
+                    } else {
+                        /*
+                         * Reset selects normal screen, a black screen
+                         * background with white characters.
+                         */
+                        if (reverseVideo == true) {
+                            /*
+                             * If in reverse video already, switch it back
+                             */
+                            invertDisplayColors();
+                        }
+                        reverseVideo = false;
+                    }
+                }
+                break;
+            case 6:
+                if (decPrivateModeFlag == true) {
+                    // DECOM
+                    if (value == true) {
+                        // Origin is relative to scroll region cursor.
+                        // Cursor can NEVER leave scrolling region.
+                        currentState.originMode = true;
+                        cursorPosition(0, 0);
+                    } else {
+                        // Origin is absolute to entire screen.  Cursor can
+                        // leave the scrolling region via cup() and hvp().
+                        currentState.originMode = false;
+                        cursorPosition(0, 0);
+                    }
+                }
+                break;
+            case 7:
+                if (decPrivateModeFlag == true) {
+                    // DECAWM
+                    if (value == true) {
+                        // Turn linewrap on
+                        currentState.lineWrap = true;
+                    } else {
+                        // Turn linewrap off
+                        currentState.lineWrap = false;
+                    }
+                }
+                break;
+            case 8:
+                if (decPrivateModeFlag == true) {
+                    // DECARM
+                    if (value == true) {
+                        // Keyboard auto-repeat on
+                        // Not supported
+                    } else {
+                        // Keyboard auto-repeat off
+                        // Not supported
+                    }
+                }
+                break;
+            case 12:
+                if (decPrivateModeFlag == false) {
+                    // SRM
+                    if (value == true) {
+                        // Local echo off
+                        fullDuplex = true;
+                    } else {
+                        // Local echo on
+                        fullDuplex = false;
+                    }
+                }
+                break;
+            case 18:
+                if (decPrivateModeFlag == true) {
+                    // DECPFF
+                    // Not supported
+                }
+                break;
+            case 19:
+                if (decPrivateModeFlag == true) {
+                    // DECPEX
+                    // Not supported
+                }
+                break;
+            case 20:
+                if (decPrivateModeFlag == false) {
+                    // LNM
+                    if (value == true) {
+                        /*
+                         * Set causes a received linefeed, form feed, or
+                         * vertical tab to move cursor to first column of
+                         * next line. RETURN transmits both a carriage return
+                         * and linefeed. This selection is also called new
+                         * line option.
+                         */
+                        newLineMode = true;
+                    } else {
+                        /*
+                         * Reset causes a received linefeed, form feed, or
+                         * vertical tab to move cursor to next line in
+                         * current column. RETURN transmits a carriage
+                         * return.
+                         */
+                        newLineMode = false;
+                    }
+                }
+                break;
+
+            case 25:
+                if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+                    if (decPrivateModeFlag == true) {
+                        // DECTCEM
+                        if (value == true) {
+                            // Visible cursor
+                            cursorVisible = true;
+                        } else {
+                            // Invisible cursor
+                            cursorVisible = false;
+                        }
+                    }
+                }
+                break;
+
+            case 42:
+                if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+                    if (decPrivateModeFlag == true) {
+                        // DECNRCM
+                        if (value == true) {
+                            // Select national mode NRC
+                            // Not supported
+                        } else {
+                            // Select multi-national mode
+                            // Not supported
+                        }
+                    }
+                }
+
+                break;
+
+            case 80:
+                if (type == DeviceType.XTERM) {
+                    if (decPrivateModeFlag == true) {
+                        if (value == true) {
+                            // Enable sixel scrolling (default).
+                            // Not supported
+                        } else {
+                            // Disable sixel scrolling.
+                            // Not supported
+                        }
+                    }
+                }
+
+                break;
+
+            case 1000:
+                if ((type == DeviceType.XTERM)
+                    && (decPrivateModeFlag == true)
+                ) {
+                    // Mouse: normal tracking mode
+                    if (value == true) {
+                        mouseProtocol = MouseProtocol.NORMAL;
+                    } else {
+                        mouseProtocol = MouseProtocol.OFF;
+                    }
+                }
+                break;
+
+            case 1002:
+                if ((type == DeviceType.XTERM)
+                    && (decPrivateModeFlag == true)
+                ) {
+                    // Mouse: normal tracking mode
+                    if (value == true) {
+                        mouseProtocol = MouseProtocol.BUTTONEVENT;
+                    } else {
+                        mouseProtocol = MouseProtocol.OFF;
+                    }
+                }
+                break;
+
+            case 1003:
+                if ((type == DeviceType.XTERM)
+                    && (decPrivateModeFlag == true)
+                ) {
+                    // Mouse: Any-event tracking mode
+                    if (value == true) {
+                        mouseProtocol = MouseProtocol.ANYEVENT;
+                    } else {
+                        mouseProtocol = MouseProtocol.OFF;
+                    }
+                }
+                break;
+
+            case 1005:
+                if ((type == DeviceType.XTERM)
+                    && (decPrivateModeFlag == true)
+                ) {
+                    // Mouse: UTF-8 coordinates
+                    if (value == true) {
+                        mouseEncoding = MouseEncoding.UTF8;
+                    } else {
+                        mouseEncoding = MouseEncoding.X10;
+                    }
+                }
+                break;
+
+            case 1006:
+                if ((type == DeviceType.XTERM)
+                    && (decPrivateModeFlag == true)
+                ) {
+                    // Mouse: SGR coordinates
+                    if (value == true) {
+                        mouseEncoding = MouseEncoding.SGR;
+                    } else {
+                        mouseEncoding = MouseEncoding.X10;
+                    }
+                }
+                break;
+
+            case 1070:
+                if (type == DeviceType.XTERM) {
+                    if (decPrivateModeFlag == true) {
+                        if (value == true) {
+                            // Use private color registers for each sixel
+                            // graphic (default).
+                            sixelPalette = null;
+                        } else {
+                            // Use shared color registers for each sixel
+                            // graphic.
+                            sixelPalette = new HashMap<Integer, java.awt.Color>();
+                        }
+                    }
+                }
+                break;
+
+            default:
+                break;
+
+            }
+        }
+    }
+
+    /**
+     * DECSC - Save cursor.
+     */
+    private void decsc() {
+        savedState.setTo(currentState);
+    }
+
+    /**
+     * DECRC - Restore cursor.
+     */
+    private void decrc() {
+        currentState.setTo(savedState);
+    }
+
+    /**
+     * IND - Index.
+     */
+    private void ind() {
+        // Move the cursor and scroll if necessary.  If at the bottom line
+        // already, a scroll up is supposed to be performed.
+        if (currentState.cursorY == scrollRegionBottom) {
+            scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom, 1);
+        }
+        cursorDown(1, true);
+    }
+
+    /**
+     * RI - Reverse index.
+     */
+    private void ri() {
+        // Move the cursor and scroll if necessary.  If at the top line
+        // already, a scroll down is supposed to be performed.
+        if (currentState.cursorY == scrollRegionTop) {
+            scrollingRegionScrollDown(scrollRegionTop, scrollRegionBottom, 1);
+        }
+        cursorUp(1, true);
+    }
+
+    /**
+     * NEL - Next line.
+     */
+    private void nel() {
+        // Move the cursor and scroll if necessary.  If at the bottom line
+        // already, a scroll up is supposed to be performed.
+        if (currentState.cursorY == scrollRegionBottom) {
+            scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom, 1);
+        }
+        cursorDown(1, true);
+
+        // Reset to the beginning of the next line
+        currentState.cursorX = 0;
+    }
+
+    /**
+     * DECKPAM - Keypad application mode.
+     */
+    private void deckpam() {
+        keypadMode = KeypadMode.Application;
+    }
+
+    /**
+     * DECKPNM - Keypad numeric mode.
+     */
+    private void deckpnm() {
+        keypadMode = KeypadMode.Numeric;
+    }
+
+    /**
+     * Move up n spaces.
+     *
+     * @param n number of spaces to move
+     * @param honorScrollRegion if true, then do nothing if the cursor is
+     * outside the scrolling region
+     */
+    private void cursorUp(final int n, final boolean honorScrollRegion) {
+        int top;
+
+        /*
+         * Special case: if a user moves the cursor from the right margin, we
+         * have to reset the VT100 right margin flag.
+         */
+        if (n > 0) {
+            wrapLineFlag = false;
+        }
+
+        for (int i = 0; i < n; i++) {
+            if (honorScrollRegion == true) {
+                // Honor the scrolling region
+                if ((currentState.cursorY < scrollRegionTop)
+                    || (currentState.cursorY > scrollRegionBottom)
+                ) {
+                    // Outside region, do nothing
+                    return;
+                }
+                // Inside region, go up
+                top = scrollRegionTop;
+            } else {
+                // Non-scrolling case
+                top = 0;
+            }
+
+            if (currentState.cursorY > top) {
+                currentState.cursorY--;
+            }
+        }
+    }
+
+    /**
+     * Move down n spaces.
+     *
+     * @param n number of spaces to move
+     * @param honorScrollRegion if true, then do nothing if the cursor is
+     * outside the scrolling region
+     */
+    private void cursorDown(final int n, final boolean honorScrollRegion) {
+        int bottom;
+
+        /*
+         * Special case: if a user moves the cursor from the right margin, we
+         * have to reset the VT100 right margin flag.
+         */
+        if (n > 0) {
+            wrapLineFlag = false;
+        }
+
+        for (int i = 0; i < n; i++) {
+
+            if (honorScrollRegion == true) {
+                // Honor the scrolling region
+                if (currentState.cursorY > scrollRegionBottom) {
+                    // Outside region, do nothing
+                    return;
+                }
+                // Inside region, go down
+                bottom = scrollRegionBottom;
+            } else {
+                // Non-scrolling case
+                bottom = height - 1;
+            }
+
+            if (currentState.cursorY < bottom) {
+                currentState.cursorY++;
+            }
+        }
+    }
+
+    /**
+     * Move left n spaces.
+     *
+     * @param n number of spaces to move
+     * @param honorScrollRegion if true, then do nothing if the cursor is
+     * outside the scrolling region
+     */
+    private void cursorLeft(final int n, final boolean honorScrollRegion) {
+        /*
+         * Special case: if a user moves the cursor from the right margin, we
+         * have to reset the VT100 right margin flag.
+         */
+        if (n > 0) {
+            wrapLineFlag = false;
+        }
+
+        for (int i = 0; i < n; i++) {
+            if (honorScrollRegion == true) {
+                // Honor the scrolling region
+                if ((currentState.cursorY < scrollRegionTop)
+                    || (currentState.cursorY > scrollRegionBottom)
+                ) {
+                    // Outside region, do nothing
+                    return;
+                }
+            }
+
+            if (currentState.cursorX > 0) {
+                currentState.cursorX--;
+            }
+        }
+    }
+
+    /**
+     * Move right n spaces.
+     *
+     * @param n number of spaces to move
+     * @param honorScrollRegion if true, then do nothing if the cursor is
+     * outside the scrolling region
+     */
+    private void cursorRight(final int n, final boolean honorScrollRegion) {
+        int rightMargin = this.rightMargin;
+
+        /*
+         * Special case: if a user moves the cursor from the right margin, we
+         * have to reset the VT100 right margin flag.
+         */
+        if (n > 0) {
+            wrapLineFlag = false;
+        }
+
+        if (display.get(currentState.cursorY).isDoubleWidth()) {
+            rightMargin = ((rightMargin + 1) / 2) - 1;
+        }
+
+        for (int i = 0; i < n; i++) {
+            if (honorScrollRegion == true) {
+                // Honor the scrolling region
+                if ((currentState.cursorY < scrollRegionTop)
+                    || (currentState.cursorY > scrollRegionBottom)
+                ) {
+                    // Outside region, do nothing
+                    return;
+                }
+            }
+
+            if (currentState.cursorX < rightMargin) {
+                currentState.cursorX++;
+            }
+        }
+    }
+
+    /**
+     * Move cursor to (col, row) where (0, 0) is the top-left corner.
+     *
+     * @param row row to move to
+     * @param col column to move to
+     */
+    private void cursorPosition(int row, final int col) {
+        int rightMargin = this.rightMargin;
+
+        assert (col >= 0);
+        assert (row >= 0);
+
+        if (display.get(currentState.cursorY).isDoubleWidth()) {
+            rightMargin = ((rightMargin + 1) / 2) - 1;
+        }
+
+        // Set column number
+        currentState.cursorX = col;
+
+        // Sanity check, bring column back to margin.
+        if (currentState.cursorX > rightMargin) {
+            currentState.cursorX = rightMargin;
+        }
+
+        // Set row number
+        if (currentState.originMode == true) {
+            row += scrollRegionTop;
+        }
+        if (currentState.cursorY < row) {
+            cursorDown(row - currentState.cursorY, false);
+        } else if (currentState.cursorY > row) {
+            cursorUp(currentState.cursorY - row, false);
+        }
+
+        wrapLineFlag = false;
+    }
+
+    /**
+     * HTS - Horizontal tabulation set.
+     */
+    private void hts() {
+        for (Integer stop: tabStops) {
+            if (stop == currentState.cursorX) {
+                // Already have a tab stop here
+                return;
+            }
+        }
+
+        // Append a tab stop to the end of the array and resort them
+        tabStops.add(currentState.cursorX);
+        Collections.sort(tabStops);
+    }
+
+    /**
+     * DECSWL - Single-width line.
+     */
+    private void decswl() {
+        display.get(currentState.cursorY).setDoubleWidth(false);
+        display.get(currentState.cursorY).setDoubleHeight(0);
+    }
+
+    /**
+     * DECDWL - Double-width line.
+     */
+    private void decdwl() {
+        display.get(currentState.cursorY).setDoubleWidth(true);
+        display.get(currentState.cursorY).setDoubleHeight(0);
+    }
+
+    /**
+     * DECHDL - Double-height + double-width line.
+     *
+     * @param topHalf if true, this sets the row to be the top half row of a
+     * double-height row
+     */
+    private void dechdl(final boolean topHalf) {
+        display.get(currentState.cursorY).setDoubleWidth(true);
+        if (topHalf == true) {
+            display.get(currentState.cursorY).setDoubleHeight(1);
+        } else {
+            display.get(currentState.cursorY).setDoubleHeight(2);
+        }
+    }
+
+    /**
+     * DECALN - Screen alignment display.
+     */
+    private void decaln() {
+        Cell newCell = new Cell('E');
+        for (DisplayLine line: display) {
+            for (int i = 0; i < line.length(); i++) {
+                line.replace(i, newCell);
+            }
+        }
+    }
+
+    /**
+     * DECSCL - Compatibility level.
+     */
+    private void decscl() {
+        int i = getCsiParam(0, 0);
+        int j = getCsiParam(1, 0);
+
+        if (i == 61) {
+            // Reset fonts
+            currentState.g0Charset = CharacterSet.US;
+            currentState.g1Charset = CharacterSet.DRAWING;
+            s8c1t = false;
+        } else if (i == 62) {
+
+            if ((j == 0) || (j == 2)) {
+                s8c1t = true;
+            } else if (j == 1) {
+                s8c1t = false;
+            }
+        }
+    }
+
+    /**
+     * CUD - Cursor down.
+     */
+    private void cud() {
+        cursorDown(getCsiParam(0, 1, 1, height), true);
+    }
+
+    /**
+     * CUF - Cursor forward.
+     */
+    private void cuf() {
+        cursorRight(getCsiParam(0, 1, 1, rightMargin + 1), true);
+    }
+
+    /**
+     * CUB - Cursor backward.
+     */
+    private void cub() {
+        cursorLeft(getCsiParam(0, 1, 1, currentState.cursorX + 1), true);
+    }
+
+    /**
+     * CUU - Cursor up.
+     */
+    private void cuu() {
+        cursorUp(getCsiParam(0, 1, 1, currentState.cursorY + 1), true);
+    }
+
+    /**
+     * CUP - Cursor position.
+     */
+    private void cup() {
+        cursorPosition(getCsiParam(0, 1, 1, height) - 1,
+            getCsiParam(1, 1, 1, rightMargin + 1) - 1);
+    }
+
+    /**
+     * CNL - Cursor down and to column 1.
+     */
+    private void cnl() {
+        cursorDown(getCsiParam(0, 1, 1, height), true);
+        // To column 0
+        cursorLeft(currentState.cursorX, true);
+    }
+
+    /**
+     * CPL - Cursor up and to column 1.
+     */
+    private void cpl() {
+        cursorUp(getCsiParam(0, 1, 1, currentState.cursorY + 1), true);
+        // To column 0
+        cursorLeft(currentState.cursorX, true);
+    }
+
+    /**
+     * CHA - Cursor to column # in current row.
+     */
+    private void cha() {
+        cursorPosition(currentState.cursorY,
+            getCsiParam(0, 1, 1, rightMargin + 1) - 1);
+    }
+
+    /**
+     * VPA - Cursor to row #, same column.
+     */
+    private void vpa() {
+        cursorPosition(getCsiParam(0, 1, 1, height) - 1,
+            currentState.cursorX);
+    }
+
+    /**
+     * ED - Erase in display.
+     */
+    private void ed() {
+        boolean honorProtected = false;
+        boolean decPrivateModeFlag = false;
+
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                decPrivateModeFlag = true;
+                break;
+            }
+        }
+
+        if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+            && (decPrivateModeFlag == true)
+        ) {
+            honorProtected = true;
+        }
+
+        int i = getCsiParam(0, 0);
+
+        if (i == 0) {
+            // Erase from here to end of screen
+            if (currentState.cursorY < height - 1) {
+                eraseScreen(currentState.cursorY + 1, 0, height - 1, width - 1,
+                    honorProtected);
+            }
+            eraseLine(currentState.cursorX, width - 1, honorProtected);
+        } else if (i == 1) {
+            // Erase from beginning of screen to here
+            eraseScreen(0, 0, currentState.cursorY - 1, width - 1,
+                honorProtected);
+            eraseLine(0, currentState.cursorX, honorProtected);
+        } else if (i == 2) {
+            // Erase entire screen
+            eraseScreen(0, 0, height - 1, width - 1, honorProtected);
+        }
+    }
+
+    /**
+     * EL - Erase in line.
+     */
+    private void el() {
+        boolean honorProtected = false;
+        boolean decPrivateModeFlag = false;
+
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                decPrivateModeFlag = true;
+                break;
+            }
+        }
+
+        if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+            && (decPrivateModeFlag == true)
+        ) {
+            honorProtected = true;
+        }
+
+        int i = getCsiParam(0, 0);
+
+        if (i == 0) {
+            // Erase from here to end of line
+            eraseLine(currentState.cursorX, width - 1, honorProtected);
+        } else if (i == 1) {
+            // Erase from beginning of line to here
+            eraseLine(0, currentState.cursorX, honorProtected);
+        } else if (i == 2) {
+            // Erase entire line
+            eraseLine(0, width - 1, honorProtected);
+        }
+    }
+
+    /**
+     * ECH - Erase # of characters in current row.
+     */
+    private void ech() {
+        int i = getCsiParam(0, 1, 1, width);
+
+        // Erase from here to i characters
+        eraseLine(currentState.cursorX, currentState.cursorX + i - 1, false);
+    }
+
+    /**
+     * IL - Insert line.
+     */
+    private void il() {
+        int i = getCsiParam(0, 1);
+
+        if ((currentState.cursorY >= scrollRegionTop)
+            && (currentState.cursorY <= scrollRegionBottom)
+        ) {
+
+            // I can get the same effect with a scroll-down
+            scrollingRegionScrollDown(currentState.cursorY,
+                scrollRegionBottom, i);
+        }
+    }
+
+    /**
+     * DCH - Delete char.
+     */
+    private void dch() {
+        int n = getCsiParam(0, 1);
+        DisplayLine line = display.get(currentState.cursorY);
+        Cell blank = new Cell();
+        for (int i = 0; i < n; i++) {
+            line.delete(currentState.cursorX, blank);
+        }
+    }
+
+    /**
+     * ICH - Insert blank char at cursor.
+     */
+    private void ich() {
+        int n = getCsiParam(0, 1);
+        DisplayLine line = display.get(currentState.cursorY);
+        Cell blank = new Cell();
+        for (int i = 0; i < n; i++) {
+            line.insert(currentState.cursorX, blank);
+        }
+    }
+
+    /**
+     * DL - Delete line.
+     */
+    private void dl() {
+        int i = getCsiParam(0, 1);
+
+        if ((currentState.cursorY >= scrollRegionTop)
+            && (currentState.cursorY <= scrollRegionBottom)) {
+
+            // I can get the same effect with a scroll-down
+            scrollingRegionScrollUp(currentState.cursorY,
+                scrollRegionBottom, i);
+        }
+    }
+
+    /**
+     * HVP - Horizontal and vertical position.
+     */
+    private void hvp() {
+        cup();
+    }
+
+    /**
+     * REP - Repeat character.
+     */
+    private void rep() {
+        int n = getCsiParam(0, 1);
+        for (int i = 0; i < n; i++) {
+            printCharacter(repCh);
+        }
+    }
+
+    /**
+     * SU - Scroll up.
+     */
+    private void su() {
+        scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom,
+            getCsiParam(0, 1, 1, height));
+    }
+
+    /**
+     * SD - Scroll down.
+     */
+    private void sd() {
+        scrollingRegionScrollDown(scrollRegionTop, scrollRegionBottom,
+            getCsiParam(0, 1, 1, height));
+    }
+
+    /**
+     * CBT - Go back X tab stops.
+     */
+    private void cbt() {
+        int tabsToMove = getCsiParam(0, 1);
+        int tabI;
+
+        for (int i = 0; i < tabsToMove; i++) {
+            int j = currentState.cursorX;
+            for (tabI = 0; tabI < tabStops.size(); tabI++) {
+                if (tabStops.get(tabI) >= currentState.cursorX) {
+                    break;
+                }
+            }
+            tabI--;
+            if (tabI <= 0) {
+                j = 0;
+            } else {
+                j = tabStops.get(tabI);
+            }
+            cursorPosition(currentState.cursorY, j);
+        }
+    }
+
+    /**
+     * CHT - Advance X tab stops.
+     */
+    private void cht() {
+        int n = getCsiParam(0, 1);
+        for (int i = 0; i < n; i++) {
+            advanceToNextTabStop();
+        }
+    }
+
+    /**
+     * SGR - Select graphics rendition.
+     */
+    private void sgr() {
+
+        if (csiParams.size() == 0) {
+            currentState.attr.reset();
+            return;
+        }
+
+        int sgrColorMode = -1;
+        boolean idx88Color = false;
+        boolean rgbColor = false;
+        int rgbRed = -1;
+        int rgbGreen = -1;
+
+        for (Integer i: csiParams) {
+
+            if ((sgrColorMode == 38) || (sgrColorMode == 48)) {
+
+                assert (type == DeviceType.XTERM);
+
+                if (idx88Color) {
+                    /*
+                     * Indexed color mode, we now have the index number.
+                     */
+                    if (sgrColorMode == 38) {
+                        currentState.attr.setForeColorRGB(get88Color(i));
+                    } else {
+                        assert (sgrColorMode == 48);
+                        currentState.attr.setBackColorRGB(get88Color(i));
+                    }
+                    sgrColorMode = -1;
+                    idx88Color = false;
+                    continue;
+                }
+
+                if (rgbColor) {
+                    /*
+                     * RGB color mode, we are collecting tokens.
+                     */
+                    if (rgbRed == -1) {
+                        rgbRed = i & 0xFF;
+                    } else if (rgbGreen == -1) {
+                        rgbGreen = i & 0xFF;
+                    } else {
+                        int rgb = rgbRed << 16;
+                        rgb |= rgbGreen << 8;
+                        rgb |= i & 0xFF;
+
+                        // System.err.printf("RGB: %08x\n", rgb);
+
+                        if (sgrColorMode == 38) {
+                            currentState.attr.setForeColorRGB(rgb);
+                        } else {
+                            assert (sgrColorMode == 48);
+                            currentState.attr.setBackColorRGB(rgb);
+                        }
+                        rgbRed = -1;
+                        rgbGreen = -1;
+                        sgrColorMode = -1;
+                        rgbColor = false;
+                    }
+                    continue;
+                }
+
+                switch (i) {
+
+                case 2:
+                    /*
+                     * RGB color mode.
+                     */
+                    rgbColor = true;
+                    continue;
+
+                case 5:
+                    /*
+                     * Indexed color mode.
+                     */
+                    idx88Color = true;
+                    continue;
+
+                default:
+                    /*
+                     * This is neither indexed nor RGB color.  Bail out.
+                     */
+                    return;
+                }
+
+            } // if ((sgrColorMode == 38) || (sgrColorMode == 48))
+
+            switch (i) {
+
+            case 0:
+                // Normal
+                currentState.attr.reset();
+                break;
+
+            case 1:
+                // Bold
+                currentState.attr.setBold(true);
+                break;
+
+            case 4:
+                // Underline
+                currentState.attr.setUnderline(true);
+                break;
+
+            case 5:
+                // Blink
+                currentState.attr.setBlink(true);
+                break;
+
+            case 7:
+                // Reverse
+                currentState.attr.setReverse(true);
+                break;
+
+            default:
+                break;
+            }
+
+            if (type == DeviceType.XTERM) {
+
+                switch (i) {
+
+                case 8:
+                    // Invisible
+                    // Not supported
+                    break;
+
+                case 90:
+                    // Set black foreground
+                    currentState.attr.setForeColorRGB(get88Color(8));
+                    break;
+                case 91:
+                    // Set red foreground
+                    currentState.attr.setForeColorRGB(get88Color(9));
+                    break;
+                case 92:
+                    // Set green foreground
+                    currentState.attr.setForeColorRGB(get88Color(10));
+                    break;
+                case 93:
+                    // Set yellow foreground
+                    currentState.attr.setForeColorRGB(get88Color(11));
+                    break;
+                case 94:
+                    // Set blue foreground
+                    currentState.attr.setForeColorRGB(get88Color(12));
+                    break;
+                case 95:
+                    // Set magenta foreground
+                    currentState.attr.setForeColorRGB(get88Color(13));
+                    break;
+                case 96:
+                    // Set cyan foreground
+                    currentState.attr.setForeColorRGB(get88Color(14));
+                    break;
+                case 97:
+                    // Set white foreground
+                    currentState.attr.setForeColorRGB(get88Color(15));
+                    break;
+
+                case 100:
+                    // Set black background
+                    currentState.attr.setBackColorRGB(get88Color(8));
+                    break;
+                case 101:
+                    // Set red background
+                    currentState.attr.setBackColorRGB(get88Color(9));
+                    break;
+                case 102:
+                    // Set green background
+                    currentState.attr.setBackColorRGB(get88Color(10));
+                    break;
+                case 103:
+                    // Set yellow background
+                    currentState.attr.setBackColorRGB(get88Color(11));
+                    break;
+                case 104:
+                    // Set blue background
+                    currentState.attr.setBackColorRGB(get88Color(12));
+                    break;
+                case 105:
+                    // Set magenta background
+                    currentState.attr.setBackColorRGB(get88Color(13));
+                    break;
+                case 106:
+                    // Set cyan background
+                    currentState.attr.setBackColorRGB(get88Color(14));
+                    break;
+                case 107:
+                    // Set white background
+                    currentState.attr.setBackColorRGB(get88Color(15));
+                    break;
+
+                default:
+                    break;
+                }
+            }
+
+            if ((type == DeviceType.VT220)
+                || (type == DeviceType.XTERM)) {
+
+                switch (i) {
+
+                case 22:
+                    // Normal intensity
+                    currentState.attr.setBold(false);
+                    break;
+
+                case 24:
+                    // No underline
+                    currentState.attr.setUnderline(false);
+                    break;
+
+                case 25:
+                    // No blink
+                    currentState.attr.setBlink(false);
+                    break;
+
+                case 27:
+                    // Un-reverse
+                    currentState.attr.setReverse(false);
+                    break;
+
+                default:
+                    break;
+                }
+            }
+
+            // A true VT100/102/220 does not support color, however everyone
+            // is used to their terminal emulator supporting color so we will
+            // unconditionally support color for all DeviceType's.
+
+            switch (i) {
+
+            case 30:
+                // Set black foreground
+                currentState.attr.setForeColor(Color.BLACK);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 31:
+                // Set red foreground
+                currentState.attr.setForeColor(Color.RED);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 32:
+                // Set green foreground
+                currentState.attr.setForeColor(Color.GREEN);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 33:
+                // Set yellow foreground
+                currentState.attr.setForeColor(Color.YELLOW);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 34:
+                // Set blue foreground
+                currentState.attr.setForeColor(Color.BLUE);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 35:
+                // Set magenta foreground
+                currentState.attr.setForeColor(Color.MAGENTA);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 36:
+                // Set cyan foreground
+                currentState.attr.setForeColor(Color.CYAN);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 37:
+                // Set white foreground
+                currentState.attr.setForeColor(Color.WHITE);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 38:
+                if (type == DeviceType.XTERM) {
+                    /*
+                     * Xterm supports T.416 / ISO-8613-3 codes to select
+                     * either an indexed color or an RGB value.  (It also
+                     * permits these ISO-8613-3 SGR sequences to be separated
+                     * by colons rather than semicolons.)
+                     *
+                     * We will support only the following:
+                     *
+                     * 1. Indexed color mode (88- or 256-color modes).
+                     *
+                     * 2. Direct RGB.
+                     *
+                     * These cover most of the use cases in the real world.
+                     *
+                     * HOWEVER, note that this is an awful broken "standard",
+                     * with no way to do it "right".  See
+                     * http://invisible-island.net/ncurses/ncurses.faq.html#xterm_16MegaColors
+                     * for a detailed discussion of the current state of RGB
+                     * in various terminals, the point of which is that none
+                     * of them really do the same thing despite all appearing
+                     * to be "xterm".
+                     *
+                     * Also see
+                     * https://bugs.kde.org/show_bug.cgi?id=107487#c3 .
+                     * where it is assumed that supporting just the "indexed
+                     * mode" of these sequences (which could align easily
+                     * with existing SGR colors) is assumed to mean full
+                     * support of 24-bit RGB.  So it is all or nothing.
+                     *
+                     * Finally, these sequences break the assumptions of
+                     * standard ECMA-48 style parsers as pointed out at
+                     * https://bugs.kde.org/show_bug.cgi?id=107487#c11 .
+                     * Therefore in order to keep a clean display, we cannot
+                     * parse anything else in this sequence.
+                     */
+                    sgrColorMode = 38;
+                    continue;
+                } else {
+                    // Underscore on, default foreground color
+                    currentState.attr.setUnderline(true);
+                    currentState.attr.setForeColor(Color.WHITE);
+                }
+                break;
+            case 39:
+                // Underscore off, default foreground color
+                currentState.attr.setUnderline(false);
+                currentState.attr.setForeColor(Color.WHITE);
+                currentState.attr.setForeColorRGB(-1);
+                break;
+            case 40:
+                // Set black background
+                currentState.attr.setBackColor(Color.BLACK);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 41:
+                // Set red background
+                currentState.attr.setBackColor(Color.RED);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 42:
+                // Set green background
+                currentState.attr.setBackColor(Color.GREEN);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 43:
+                // Set yellow background
+                currentState.attr.setBackColor(Color.YELLOW);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 44:
+                // Set blue background
+                currentState.attr.setBackColor(Color.BLUE);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 45:
+                // Set magenta background
+                currentState.attr.setBackColor(Color.MAGENTA);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 46:
+                // Set cyan background
+                currentState.attr.setBackColor(Color.CYAN);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 47:
+                // Set white background
+                currentState.attr.setBackColor(Color.WHITE);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+            case 48:
+                if (type == DeviceType.XTERM) {
+                    /*
+                     * Xterm supports T.416 / ISO-8613-3 codes to select
+                     * either an indexed color or an RGB value.  (It also
+                     * permits these ISO-8613-3 SGR sequences to be separated
+                     * by colons rather than semicolons.)
+                     *
+                     * We will support only the following:
+                     *
+                     * 1. Indexed color mode (88- or 256-color modes).
+                     *
+                     * 2. Direct RGB.
+                     *
+                     * These cover most of the use cases in the real world.
+                     *
+                     * HOWEVER, note that this is an awful broken "standard",
+                     * with no way to do it "right".  See
+                     * http://invisible-island.net/ncurses/ncurses.faq.html#xterm_16MegaColors
+                     * for a detailed discussion of the current state of RGB
+                     * in various terminals, the point of which is that none
+                     * of them really do the same thing despite all appearing
+                     * to be "xterm".
+                     *
+                     * Also see
+                     * https://bugs.kde.org/show_bug.cgi?id=107487#c3 .
+                     * where it is assumed that supporting just the "indexed
+                     * mode" of these sequences (which could align easily
+                     * with existing SGR colors) is assumed to mean full
+                     * support of 24-bit RGB.  So it is all or nothing.
+                     *
+                     * Finally, these sequences break the assumptions of
+                     * standard ECMA-48 style parsers as pointed out at
+                     * https://bugs.kde.org/show_bug.cgi?id=107487#c11 .
+                     * Therefore in order to keep a clean display, we cannot
+                     * parse anything else in this sequence.
+                     */
+                    sgrColorMode = 48;
+                    continue;
+                }
+                break;
+            case 49:
+                // Default background
+                currentState.attr.setBackColor(Color.BLACK);
+                currentState.attr.setBackColorRGB(-1);
+                break;
+
+            default:
+                break;
+            }
+        }
+    }
+
+    /**
+     * DA - Device attributes.
+     */
+    private void da() {
+        int extendedFlag = 0;
+        int i = 0;
+        if (collectBuffer.length() > 0) {
+            String args = collectBuffer.substring(1);
+            if (collectBuffer.charAt(0) == '>') {
+                extendedFlag = 1;
+                if (collectBuffer.length() >= 2) {
+                    i = Integer.parseInt(args);
+                }
+            } else if (collectBuffer.charAt(0) == '=') {
+                extendedFlag = 2;
+                if (collectBuffer.length() >= 2) {
+                    i = Integer.parseInt(args);
+                }
+            } else {
+                // Unknown code, bail out
+                return;
+            }
+        }
+
+        if ((i != 0) && (i != 1)) {
+            return;
+        }
+
+        if ((extendedFlag == 0) && (i == 0)) {
+            // Send string directly to remote side
+            writeRemote(deviceTypeResponse());
+            return;
+        }
+
+        if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+
+            if ((extendedFlag == 1) && (i == 0)) {
+                /*
+                 * Request "What type of terminal are you, what is your
+                 * firmware version, and what hardware options do you have
+                 * installed?"
+                 *
+                 * Respond: "I am a VT220 (identification code of 1), my
+                 * firmware version is _____ (Pv), and I have _____ Po
+                 * options installed."
+                 *
+                 * (Same as xterm)
+                 *
+                 */
+
+                if (s8c1t == true) {
+                    writeRemote("\u009b>1;10;0c");
+                } else {
+                    writeRemote("\033[>1;10;0c");
+                }
+            }
+        }
+
+        // VT420 and up
+        if ((extendedFlag == 2) && (i == 0)) {
+
+            /*
+             * Request "What is your unit ID?"
+             *
+             * Respond: "I was manufactured at site 00 and have a unique ID
+             * number of 123."
+             *
+             */
+            writeRemote("\033P!|00010203\033\\");
+        }
+    }
+
+    /**
+     * DECSTBM - Set top and bottom margins.
+     */
+    private void decstbm() {
+        boolean decPrivateModeFlag = false;
+
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                decPrivateModeFlag = true;
+                break;
+            }
+        }
+        if (decPrivateModeFlag) {
+            // This could be restore DEC private mode values.
+            // Ignore it.
+        } else {
+            // DECSTBM
+            int top = getCsiParam(0, 1, 1, height) - 1;
+            int bottom = getCsiParam(1, height, 1, height) - 1;
+            if (bottom > height - 1) {
+                bottom = height - 1;
+            }
+
+            if (top > bottom) {
+                top = bottom;
+            }
+            scrollRegionTop = top;
+            scrollRegionBottom = bottom;
+
+            // Home cursor
+            cursorPosition(0, 0);
+        }
+    }
+
+    /**
+     * DECREQTPARM - Request terminal parameters.
+     */
+    private void decreqtparm() {
+        int i = getCsiParam(0, 0);
+
+        if ((i != 0) && (i != 1)) {
+                return;
+        }
+
+        String str = "";
+
+        /*
+         * Request terminal parameters.
+         *
+         * Respond with:
+         *
+         *     Parity NONE, 8 bits, xmitspeed 38400, recvspeed 38400.
+         *     (CLoCk MULtiplier = 1, STP option flags = 0)
+         *
+         * (Same as xterm)
+         */
+        if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+            && (s8c1t == true)
+        ) {
+            str = String.format("\u009b%d;1;1;128;128;1;0x", i + 2);
+        } else {
+            str = String.format("\033[%d;1;1;128;128;1;0x", i + 2);
+        }
+        writeRemote(str);
+    }
+
+    /**
+     * DECSCA - Select Character Attributes.
+     */
+    private void decsca() {
+        int i = getCsiParam(0, 0);
+
+        if ((i == 0) || (i == 2)) {
+            // Protect mode OFF
+            currentState.attr.setProtect(false);
+        }
+        if (i == 1) {
+            // Protect mode ON
+            currentState.attr.setProtect(true);
+        }
+    }
+
+    /**
+     * DECSTR - Soft Terminal Reset.
+     */
+    private void decstr() {
+        // Do exactly like RIS - Reset to initial state
+        reset();
+        // Do I clear screen too? I think so...
+        eraseScreen(0, 0, height - 1, width - 1, false);
+        cursorPosition(0, 0);
+    }
+
+    /**
+     * DSR - Device status report.
+     */
+    private void dsr() {
+        boolean decPrivateModeFlag = false;
+        int row = currentState.cursorY;
+
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                decPrivateModeFlag = true;
+                break;
+            }
+        }
+
+        int i = getCsiParam(0, 0);
+
+        switch (i) {
+
+        case 5:
+            // Request status report. Respond with "OK, no malfunction."
+
+            // Send string directly to remote side
+            if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                && (s8c1t == true)
+            ) {
+                writeRemote("\u009b0n");
+            } else {
+                writeRemote("\033[0n");
+            }
+            break;
+
+        case 6:
+            // Request cursor position.  Respond with current position.
+            if (currentState.originMode == true) {
+                row -= scrollRegionTop;
+            }
+            String str = "";
+            if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                && (s8c1t == true)
+            ) {
+                str = String.format("\u009b%d;%dR", row + 1,
+                    currentState.cursorX + 1);
+            } else {
+                str = String.format("\033[%d;%dR", row + 1,
+                    currentState.cursorX + 1);
+            }
+
+            // Send string directly to remote side
+            writeRemote(str);
+            break;
+
+        case 15:
+            if (decPrivateModeFlag == true) {
+
+                // Request printer status report.  Respond with "Printer not
+                // connected."
+
+                if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                    && (s8c1t == true)) {
+                    writeRemote("\u009b?13n");
+                } else {
+                    writeRemote("\033[?13n");
+                }
+            }
+            break;
+
+        case 25:
+            if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                && (decPrivateModeFlag == true)
+            ) {
+
+                // Request user-defined keys are locked or unlocked.  Respond
+                // with "User-defined keys are locked."
+
+                if (s8c1t == true) {
+                    writeRemote("\u009b?21n");
+                } else {
+                    writeRemote("\033[?21n");
+                }
+            }
+            break;
+
+        case 26:
+            if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                && (decPrivateModeFlag == true)
+            ) {
+
+                // Request keyboard language.  Respond with "Keyboard
+                // language is North American."
+
+                if (s8c1t == true) {
+                    writeRemote("\u009b?27;1n");
+                } else {
+                    writeRemote("\033[?27;1n");
+                }
+
+            }
+            break;
+
+        default:
+            // Some other option, ignore
+            break;
+        }
+    }
+
+    /**
+     * TBC - Tabulation clear.
+     */
+    private void tbc() {
+        int i = getCsiParam(0, 0);
+        if (i == 0) {
+            List<Integer> newStops = new ArrayList<Integer>();
+            for (Integer stop: tabStops) {
+                if (stop == currentState.cursorX) {
+                    continue;
+                }
+                newStops.add(stop);
+            }
+            tabStops = newStops;
+        }
+        if (i == 3) {
+            tabStops.clear();
+        }
+    }
+
+    /**
+     * Erase the characters in the current line from the start column to the
+     * end column, inclusive.
+     *
+     * @param start starting column to erase (between 0 and width - 1)
+     * @param end ending column to erase (between 0 and width - 1)
+     * @param honorProtected if true, do not erase characters with the
+     * protected attribute set
+     */
+    private void eraseLine(int start, int end, final boolean honorProtected) {
+
+        if (start > end) {
+            return;
+        }
+        if (end > width - 1) {
+            end = width - 1;
+        }
+        if (start < 0) {
+            start = 0;
+        }
+
+        for (int i = start; i <= end; i++) {
+            DisplayLine line = display.get(currentState.cursorY);
+            if ((!honorProtected)
+                || ((honorProtected) && (!line.charAt(i).isProtect()))) {
+
+                switch (type) {
+                case VT100:
+                case VT102:
+                case VT220:
+                    /*
+                     * From the VT102 manual:
+                     *
+                     * Erasing a character also erases any character
+                     * attribute of the character.
+                     */
+                    line.setBlank(i);
+                    break;
+                case XTERM:
+                    /*
+                     * Erase with the current color a.k.a. back-color erase
+                     * (bce).
+                     */
+                    line.setChar(i, ' ');
+                    line.setAttr(i, currentState.attr);
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * Erase a rectangular section of the screen, inclusive.  end column,
+     * inclusive.
+     *
+     * @param startRow starting row to erase (between 0 and height - 1)
+     * @param startCol starting column to erase (between 0 and width - 1)
+     * @param endRow ending row to erase (between 0 and height - 1)
+     * @param endCol ending column to erase (between 0 and width - 1)
+     * @param honorProtected if true, do not erase characters with the
+     * protected attribute set
+     */
+    private void eraseScreen(final int startRow, final int startCol,
+        final int endRow, final int endCol, final boolean honorProtected) {
+
+        int oldCursorY;
+
+        if ((startRow < 0)
+            || (startCol < 0)
+            || (endRow < 0)
+            || (endCol < 0)
+            || (endRow < startRow)
+            || (endCol < startCol)
+        ) {
+            return;
+        }
+
+        oldCursorY = currentState.cursorY;
+        for (int i = startRow; i <= endRow; i++) {
+            currentState.cursorY = i;
+            eraseLine(startCol, endCol, honorProtected);
+
+            // Erase display clears the double attributes
+            display.get(i).setDoubleWidth(false);
+            display.get(i).setDoubleHeight(0);
+        }
+        currentState.cursorY = oldCursorY;
+    }
+
+    /**
+     * VT220 printer functions.  All of these are parsed, but won't do
+     * anything.
+     */
+    private void printerFunctions() {
+        boolean decPrivateModeFlag = false;
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                decPrivateModeFlag = true;
+                break;
+            }
+        }
+
+        int i = getCsiParam(0, 0);
+
+        switch (i) {
+
+        case 0:
+            if (decPrivateModeFlag == false) {
+                // Print screen
+            }
+            break;
+
+        case 1:
+            if (decPrivateModeFlag == true) {
+                // Print cursor line
+            }
+            break;
+
+        case 4:
+            if (decPrivateModeFlag == true) {
+                // Auto print mode OFF
+            } else {
+                // Printer controller OFF
+
+                // Characters re-appear on the screen
+                printerControllerMode = false;
+            }
+            break;
+
+        case 5:
+            if (decPrivateModeFlag == true) {
+                // Auto print mode
+
+            } else {
+                // Printer controller
+
+                // Characters get sucked into oblivion
+                printerControllerMode = true;
+            }
+            break;
+
+        default:
+            break;
+
+        }
+    }
+
+    /**
+     * Handle the SCAN_OSC_STRING state.  Handle this in VT100 because lots
+     * of remote systems will send an XTerm title sequence even if TERM isn't
+     * xterm.
+     *
+     * @param xtermChar the character received from the remote side
+     */
+    private void oscPut(final char xtermChar) {
+        // System.err.println("oscPut: " + xtermChar);
+
+        boolean oscEnd = false;
+
+        if (xtermChar == 0x07) {
+            oscEnd = true;
+        }
+        if ((xtermChar == '\\')
+            && (collectBuffer.charAt(collectBuffer.length() - 1) == '\033')
+        ) {
+            oscEnd = true;
+        }
+
+        // Collect first
+        collectBuffer.append(xtermChar);
+
+        // Xterm cases...
+        if (oscEnd) {
+            String args = null;
+            if (xtermChar == 0x07) {
+                args = collectBuffer.substring(0, collectBuffer.length() - 1);
+            } else {
+                args = collectBuffer.substring(0, collectBuffer.length() - 2);
+            }
+
+            String [] p = args.split(";");
+            if (p.length > 0) {
+                if ((p[0].equals("0")) || (p[0].equals("2"))) {
+                    if (p.length > 1) {
+                        // Screen title
+                        screenTitle = p[1];
+                    }
+                }
+
+                if (p[0].equals("4")) {
+                    for (int i = 1; i + 1 < p.length; i += 2) {
+                        // Set a color index value
+                        try {
+                            set88Color(Integer.parseInt(p[i]), p[i + 1]);
+                        } catch (NumberFormatException e) {
+                            // SQUASH
+                        }
+                    }
+                }
+
+                if (p[0].equals("10")) {
+                    if (p[1].equals("?")) {
+                        // Respond with foreground color.
+                        java.awt.Color color = jexer.backend.SwingTerminal.attrToForegroundColor(currentState.attr);
+
+                        writeRemote(String.format(
+                            "\033]10;rgb:%04x/%04x/%04x\033\\",
+                                color.getRed() << 8,
+                                color.getGreen() << 8,
+                                color.getBlue() << 8));
+                    }
+                }
+
+                if (p[0].equals("11")) {
+                    if (p[1].equals("?")) {
+                        // Respond with background color.
+                        java.awt.Color color = jexer.backend.SwingTerminal.attrToBackgroundColor(currentState.attr);
+
+                        writeRemote(String.format(
+                            "\033]11;rgb:%04x/%04x/%04x\033\\",
+                                color.getRed() << 8,
+                                color.getGreen() << 8,
+                                color.getBlue() << 8));
+                    }
+                }
+
+                if (p[0].equals("444")) {
+                    if (p[1].equals("0") && (p.length == 6)) {
+                        // Jexer image - RGB
+                        parseJexerImageRGB(p[2], p[3], p[4], p[5]);
+                    } else if (p[1].equals("1") && (p.length == 4)) {
+                        // Jexer image - PNG
+                        parseJexerImageFile(1, p[2], p[3]);
+                    } else if (p[1].equals("2") && (p.length == 4)) {
+                        // Jexer image - JPG
+                        parseJexerImageFile(2, p[2], p[3]);
+                    }
+                }
+            }
+
+            // Go to SCAN_GROUND state
+            toGround();
+            return;
+        }
+    }
+
+    /**
+     * Handle the SCAN_SOSPMAPC_STRING state.  This is currently only used by
+     * Jexer ECMA48Terminal to talk to ECMA48.
+     *
+     * @param pmChar the character received from the remote side
+     */
+    private void pmPut(final char pmChar) {
+        // System.err.println("pmPut: " + pmChar);
+
+        boolean pmEnd = false;
+
+        if ((pmChar == '\\')
+            && (collectBuffer.charAt(collectBuffer.length() - 1) == '\033')
+        ) {
+            pmEnd = true;
+        }
+
+        // Collect first
+        collectBuffer.append(pmChar);
+
+        // Xterm cases...
+        if (pmEnd) {
+            String arg = null;
+            arg = collectBuffer.substring(0, collectBuffer.length() - 2);
+
+            // System.err.println("arg: '" + arg + "'");
+
+            if (arg.equals("hideMousePointer")) {
+                hideMousePointer = true;
+            }
+            if (arg.equals("showMousePointer")) {
+                hideMousePointer = false;
+            }
+
+            // Go to SCAN_GROUND state
+            toGround();
+            return;
+        }
+    }
+
+    /**
+     * Perform xterm window operations.
+     */
+    private void xtermWindowOps() {
+        boolean xtermPrivateModeFlag = false;
+
+        for (int i = 0; i < collectBuffer.length(); i++) {
+            if (collectBuffer.charAt(i) == '?') {
+                xtermPrivateModeFlag = true;
+                break;
+            }
+        }
+
+        int i = getCsiParam(0, 0);
+
+        if (!xtermPrivateModeFlag) {
+            switch (i) {
+            case 14:
+                // Report xterm text area size in pixels as CSI 4 ; height ;
+                // width t
+                writeRemote(String.format("\033[4;%d;%dt", textHeight * height,
+                        textWidth * width));
+                break;
+            case 16:
+                // Report character size in pixels as CSI 6 ; height ; width
+                // t
+                writeRemote(String.format("\033[6;%d;%dt", textHeight,
+                        textWidth));
+                break;
+            case 18:
+                // Report the text are size in characters as CSI 8 ; height ;
+                // width t
+                writeRemote(String.format("\033[8;%d;%dt", height, width));
+                break;
+            default:
+                break;
+            }
+        }
+    }
+
+    /**
+     * Respond to xterm sixel query.
+     */
+    private void xtermSixelQuery() {
+        int item = getCsiParam(0, 0);
+        int action = getCsiParam(1, 0);
+        int value = getCsiParam(2, 0);
+
+        switch (item) {
+        case 1:
+            if (action == 1) {
+                // Report number of color registers.
+                writeRemote(String.format("\033[?%d;%d;%dS", item, 0, 1024));
+                return;
+            }
+            break;
+        default:
+            break;
+        }
+        // We will not support this option.
+        writeRemote(String.format("\033[?%d;%dS", item, action));
+    }
+
+    /**
+     * Run this input character through the ECMA48 state machine.
+     *
+     * @param ch character from the remote side
+     */
+    private void consume(final int ch) {
+        readCount++;
+
+        // DEBUG
+        // System.err.printf("%c STATE = %s\n", ch, scanState);
+
+        // Special "anywhere" states
+
+        // 18, 1A                     --> execute, then switch to SCAN_GROUND
+        if ((ch == 0x18) || (ch == 0x1A)) {
+            // CAN and SUB abort escape sequences
+            toGround();
+            return;
+        }
+
+        // 80-8F, 91-97, 99, 9A, 9C   --> execute, then switch to SCAN_GROUND
+
+        // 0x1B == ESCAPE
+        if (ch == 0x1B) {
+            if ((type == DeviceType.XTERM)
+                && ((scanState == ScanState.OSC_STRING)
+                    || (scanState == ScanState.DCS_SIXEL)
+                    || (scanState == ScanState.SOSPMAPC_STRING))
+            ) {
+                // Xterm can pass ESCAPE to its OSC sequence.
+                // Xterm can pass ESCAPE to its DCS sequence.
+                // Jexer can pass ESCAPE to its PM sequence.
+            } else if ((scanState != ScanState.DCS_ENTRY)
+                && (scanState != ScanState.DCS_INTERMEDIATE)
+                && (scanState != ScanState.DCS_IGNORE)
+                && (scanState != ScanState.DCS_PARAM)
+                && (scanState != ScanState.DCS_PASSTHROUGH)
+            ) {
+                scanState = ScanState.ESCAPE;
+                return;
+            }
+        }
+
+        // 0x9B == CSI 8-bit sequence
+        if (ch == 0x9B) {
+            scanState = ScanState.CSI_ENTRY;
+            return;
+        }
+
+        // 0x9D goes to ScanState.OSC_STRING
+        if (ch == 0x9D) {
+            scanState = ScanState.OSC_STRING;
+            return;
+        }
+
+        // 0x90 goes to DCS_ENTRY
+        if (ch == 0x90) {
+            scanState = ScanState.DCS_ENTRY;
+            return;
+        }
+
+        // 0x98, 0x9E, and 0x9F go to SOSPMAPC_STRING
+        if ((ch == 0x98) || (ch == 0x9E) || (ch == 0x9F)) {
+            scanState = ScanState.SOSPMAPC_STRING;
+            return;
+        }
+
+        // 0x7F (DEL) is always discarded
+        if (ch == 0x7F) {
+            return;
+        }
+
+        switch (scanState) {
+
+        case GROUND:
+            // 00-17, 19, 1C-1F --> execute
+            // 80-8F, 91-9A, 9C --> execute
+            if ((ch <= 0x1F) || ((ch >= 0x80) && (ch <= 0x9F))) {
+                handleControlChar((char) ch);
+            }
+
+            // 20-7F            --> print
+            if (((ch >= 0x20) && (ch <= 0x7F))
+                || (ch >= 0xA0)
+            ) {
+
+                // VT220 printer --> trash bin
+                if (((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM))
+                    && (printerControllerMode == true)
+                ) {
+                    return;
+                }
+
+                // Hang onto this character
+                repCh = mapCharacter(ch);
+
+                // Print this character
+                printCharacter(repCh);
+            }
+            return;
+
+        case ESCAPE:
+            // 00-17, 19, 1C-1F --> execute
+            if (ch <= 0x1F) {
+                handleControlChar((char) ch);
+                return;
+            }
+
+            // 20-2F            --> collect, then switch to ESCAPE_INTERMEDIATE
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+                scanState = ScanState.ESCAPE_INTERMEDIATE;
+                return;
+            }
+
+            // 30-4F, 51-57, 59, 5A, 5C, 60-7E --> dispatch, then switch to GROUND
+            if ((ch >= 0x30) && (ch <= 0x4F)) {
+                switch (ch) {
+                case '0':
+                case '1':
+                case '2':
+                case '3':
+                case '4':
+                case '5':
+                case '6':
+                    break;
+                case '7':
+                    // DECSC - Save cursor
+                    // Note this code overlaps both ANSI and VT52 mode
+                    decsc();
+                    break;
+
+                case '8':
+                    // DECRC - Restore cursor
+                    // Note this code overlaps both ANSI and VT52 mode
+                    decrc();
+                    break;
+
+                case '9':
+                case ':':
+                case ';':
+                    break;
+                case '<':
+                    if (vt52Mode == true) {
+                        // DECANM - Enter ANSI mode
+                        vt52Mode = false;
+                        arrowKeyMode = ArrowKeyMode.VT100;
+
+                        /*
+                         * From the VT102 docs: "You use ANSI mode to select
+                         * most terminal features; the terminal uses the same
+                         * features when it switches to VT52 mode. You
+                         * cannot, however, change most of these features in
+                         * VT52 mode."
+                         *
+                         * In other words, do not reset any other attributes
+                         * when switching between VT52 submode and ANSI.
+                         */
+
+                        // Reset fonts
+                        currentState.g0Charset = CharacterSet.US;
+                        currentState.g1Charset = CharacterSet.DRAWING;
+                        s8c1t = false;
+                        singleshift = Singleshift.NONE;
+                        currentState.glLockshift = LockshiftMode.NONE;
+                        currentState.grLockshift = LockshiftMode.NONE;
+                    }
+                    break;
+                case '=':
+                    // DECKPAM - Keypad application mode
+                    // Note this code overlaps both ANSI and VT52 mode
+                    deckpam();
+                    break;
+                case '>':
+                    // DECKPNM - Keypad numeric mode
+                    // Note this code overlaps both ANSI and VT52 mode
+                    deckpnm();
+                    break;
+                case '?':
+                case '@':
+                    break;
+                case 'A':
+                    if (vt52Mode == true) {
+                        // Cursor up, and stop at the top without scrolling
+                        cursorUp(1, false);
+                    }
+                    break;
+                case 'B':
+                    if (vt52Mode == true) {
+                        // Cursor down, and stop at the bottom without scrolling
+                        cursorDown(1, false);
+                    }
+                    break;
+                case 'C':
+                    if (vt52Mode == true) {
+                        // Cursor right, and stop at the right without scrolling
+                        cursorRight(1, false);
+                    }
+                    break;
+                case 'D':
+                    if (vt52Mode == true) {
+                        // Cursor left, and stop at the left without scrolling
+                        cursorLeft(1, false);
+                    } else {
+                        // IND - Index
+                        ind();
+                    }
+                    break;
+                case 'E':
+                    if (vt52Mode == true) {
+                        // Nothing
+                    } else {
+                        // NEL - Next line
+                        nel();
+                    }
+                    break;
+                case 'F':
+                    if (vt52Mode == true) {
+                        // G0 --> Special graphics
+                        currentState.g0Charset = CharacterSet.VT52_GRAPHICS;
+                    }
+                    break;
+                case 'G':
+                    if (vt52Mode == true) {
+                        // G0 --> ASCII set
+                        currentState.g0Charset = CharacterSet.US;
+                    }
+                    break;
+                case 'H':
+                    if (vt52Mode == true) {
+                        // Cursor to home
+                        cursorPosition(0, 0);
+                    } else {
+                        // HTS - Horizontal tabulation set
+                        hts();
+                    }
+                    break;
+                case 'I':
+                    if (vt52Mode == true) {
+                        // Reverse line feed.  Same as RI.
+                        ri();
+                    }
+                    break;
+                case 'J':
+                    if (vt52Mode == true) {
+                        // Erase to end of screen
+                        eraseLine(currentState.cursorX, width - 1, false);
+                        eraseScreen(currentState.cursorY + 1, 0, height - 1,
+                            width - 1, false);
+                    }
+                    break;
+                case 'K':
+                    if (vt52Mode == true) {
+                        // Erase to end of line
+                        eraseLine(currentState.cursorX, width - 1, false);
+                    }
+                    break;
+                case 'L':
+                    break;
+                case 'M':
+                    if (vt52Mode == true) {
+                        // Nothing
+                    } else {
+                        // RI - Reverse index
+                        ri();
+                    }
+                    break;
+                case 'N':
+                    if (vt52Mode == false) {
+                        // SS2
+                        singleshift = Singleshift.SS2;
+                    }
+                    break;
+                case 'O':
+                    if (vt52Mode == false) {
+                        // SS3
+                        singleshift = Singleshift.SS3;
+                    }
+                    break;
+                }
+                toGround();
+                return;
+            }
+            if ((ch >= 0x51) && (ch <= 0x57)) {
+                switch (ch) {
+                case 'Q':
+                case 'R':
+                case 'S':
+                case 'T':
+                case 'U':
+                case 'V':
+                case 'W':
+                    break;
+                }
+                toGround();
+                return;
+            }
+            if (ch == 0x59) {
+                // 'Y'
+                if (vt52Mode == true) {
+                    scanState = ScanState.VT52_DIRECT_CURSOR_ADDRESS;
+                } else {
+                    toGround();
+                }
+                return;
+            }
+            if (ch == 0x5A) {
+                // 'Z'
+                if (vt52Mode == true) {
+                    // Identify
+                    // Send string directly to remote side
+                    writeRemote("\033/Z");
+                } else {
+                    // DECID
+                    // Send string directly to remote side
+                    writeRemote(deviceTypeResponse());
+                }
+                toGround();
+                return;
+            }
+            if (ch == 0x5C) {
+                // '\'
+                toGround();
+                return;
+            }
+
+            // VT52 cannot get to any of these other states
+            if (vt52Mode == true) {
+                toGround();
+                return;
+            }
+
+            if ((ch >= 0x60) && (ch <= 0x7E)) {
+                switch (ch) {
+                case '`':
+                case 'a':
+                case 'b':
+                    break;
+                case 'c':
+                    // RIS - Reset to initial state
+                    reset();
+                    // Do I clear screen too? I think so...
+                    eraseScreen(0, 0, height - 1, width - 1, false);
+                    cursorPosition(0, 0);
+                    break;
+                case 'd':
+                case 'e':
+                case 'f':
+                case 'g':
+                case 'h':
+                case 'i':
+                case 'j':
+                case 'k':
+                case 'l':
+                case 'm':
+                    break;
+                case 'n':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // VT220 lockshift G2 into GL
+                        currentState.glLockshift = LockshiftMode.G2_GL;
+                        shiftOut = false;
+                    }
+                    break;
+                case 'o':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // VT220 lockshift G3 into GL
+                        currentState.glLockshift = LockshiftMode.G3_GL;
+                        shiftOut = false;
+                    }
+                    break;
+                case 'p':
+                case 'q':
+                case 'r':
+                case 's':
+                case 't':
+                case 'u':
+                case 'v':
+                case 'w':
+                case 'x':
+                case 'y':
+                case 'z':
+                case '{':
+                    break;
+                case '|':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // VT220 lockshift G3 into GR
+                        currentState.grLockshift = LockshiftMode.G3_GR;
+                        shiftOut = false;
+                    }
+                    break;
+                case '}':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // VT220 lockshift G2 into GR
+                        currentState.grLockshift = LockshiftMode.G2_GR;
+                        shiftOut = false;
+                    }
+                    break;
+
+                case '~':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // VT220 lockshift G1 into GR
+                        currentState.grLockshift = LockshiftMode.G1_GR;
+                        shiftOut = false;
+                    }
+                    break;
+                }
+                toGround();
+            }
+
+            // 7F               --> ignore
+
+            // 0x5B goes to CSI_ENTRY
+            if (ch == 0x5B) {
+                scanState = ScanState.CSI_ENTRY;
+            }
+
+            // 0x5D goes to OSC_STRING
+            if (ch == 0x5D) {
+                scanState = ScanState.OSC_STRING;
+            }
+
+            // 0x50 goes to DCS_ENTRY
+            if (ch == 0x50) {
+                scanState = ScanState.DCS_ENTRY;
+            }
+
+            // 0x58, 0x5E, and 0x5F go to SOSPMAPC_STRING
+            if ((ch == 0x58) || (ch == 0x5E) || (ch == 0x5F)) {
+                scanState = ScanState.SOSPMAPC_STRING;
+            }
+
+            return;
+
+        case ESCAPE_INTERMEDIATE:
+            // 00-17, 19, 1C-1F    --> execute
+            if (ch <= 0x1F) {
+                handleControlChar((char) ch);
+            }
+
+            // 20-2F               --> collect
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+            }
+
+            // 30-7E               --> dispatch, then switch to GROUND
+            if ((ch >= 0x30) && (ch <= 0x7E)) {
+                switch (ch) {
+                case '0':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '(')) {
+                        // G0 --> Special graphics
+                        currentState.g0Charset = CharacterSet.DRAWING;
+                    }
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == ')')) {
+                        // G1 --> Special graphics
+                        currentState.g1Charset = CharacterSet.DRAWING;
+                    }
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> Special graphics
+                            currentState.g2Charset = CharacterSet.DRAWING;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> Special graphics
+                            currentState.g3Charset = CharacterSet.DRAWING;
+                        }
+                    }
+                    break;
+                case '1':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '(')) {
+                        // G0 --> Alternate character ROM standard character set
+                        currentState.g0Charset = CharacterSet.ROM;
+                    }
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == ')')) {
+                        // G1 --> Alternate character ROM standard character set
+                        currentState.g1Charset = CharacterSet.ROM;
+                    }
+                    break;
+                case '2':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '(')) {
+                        // G0 --> Alternate character ROM special graphics
+                        currentState.g0Charset = CharacterSet.ROM_SPECIAL;
+                    }
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == ')')) {
+                        // G1 --> Alternate character ROM special graphics
+                        currentState.g1Charset = CharacterSet.ROM_SPECIAL;
+                    }
+                    break;
+                case '3':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '#')) {
+                        // DECDHL - Double-height line (top half)
+                        dechdl(true);
+                    }
+                    break;
+                case '4':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '#')) {
+                        // DECDHL - Double-height line (bottom half)
+                        dechdl(false);
+                    }
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> DUTCH
+                            currentState.g0Charset = CharacterSet.NRC_DUTCH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> DUTCH
+                            currentState.g1Charset = CharacterSet.NRC_DUTCH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> DUTCH
+                            currentState.g2Charset = CharacterSet.NRC_DUTCH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> DUTCH
+                            currentState.g3Charset = CharacterSet.NRC_DUTCH;
+                        }
+                    }
+                    break;
+                case '5':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '#')) {
+                        // DECSWL - Single-width line
+                        decswl();
+                    }
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> FINNISH
+                            currentState.g0Charset = CharacterSet.NRC_FINNISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> FINNISH
+                            currentState.g1Charset = CharacterSet.NRC_FINNISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> FINNISH
+                            currentState.g2Charset = CharacterSet.NRC_FINNISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> FINNISH
+                            currentState.g3Charset = CharacterSet.NRC_FINNISH;
+                        }
+                    }
+                    break;
+                case '6':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '#')) {
+                        // DECDWL - Double-width line
+                        decdwl();
+                    }
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> NORWEGIAN
+                            currentState.g0Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> NORWEGIAN
+                            currentState.g1Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> NORWEGIAN
+                            currentState.g2Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> NORWEGIAN
+                            currentState.g3Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                    }
+                    break;
+                case '7':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> SWEDISH
+                            currentState.g0Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> SWEDISH
+                            currentState.g1Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> SWEDISH
+                            currentState.g2Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> SWEDISH
+                            currentState.g3Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                    }
+                    break;
+                case '8':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '#')) {
+                        // DECALN - Screen alignment display
+                        decaln();
+                    }
+                    break;
+                case '9':
+                case ':':
+                case ';':
+                    break;
+                case '<':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> DEC_SUPPLEMENTAL
+                            currentState.g0Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> DEC_SUPPLEMENTAL
+                            currentState.g1Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> DEC_SUPPLEMENTAL
+                            currentState.g2Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> DEC_SUPPLEMENTAL
+                            currentState.g3Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                        }
+                    }
+                    break;
+                case '=':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> SWISS
+                            currentState.g0Charset = CharacterSet.NRC_SWISS;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> SWISS
+                            currentState.g1Charset = CharacterSet.NRC_SWISS;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> SWISS
+                            currentState.g2Charset = CharacterSet.NRC_SWISS;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> SWISS
+                            currentState.g3Charset = CharacterSet.NRC_SWISS;
+                        }
+                    }
+                    break;
+                case '>':
+                case '?':
+                case '@':
+                    break;
+                case 'A':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '(')) {
+                        // G0 --> United Kingdom set
+                        currentState.g0Charset = CharacterSet.UK;
+                    }
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == ')')) {
+                        // G1 --> United Kingdom set
+                        currentState.g1Charset = CharacterSet.UK;
+                    }
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> United Kingdom set
+                            currentState.g2Charset = CharacterSet.UK;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> United Kingdom set
+                            currentState.g3Charset = CharacterSet.UK;
+                        }
+                    }
+                    break;
+                case 'B':
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == '(')) {
+                        // G0 --> ASCII set
+                        currentState.g0Charset = CharacterSet.US;
+                    }
+                    if ((collectBuffer.length() == 1)
+                        && (collectBuffer.charAt(0) == ')')) {
+                        // G1 --> ASCII set
+                        currentState.g1Charset = CharacterSet.US;
+                    }
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> ASCII
+                            currentState.g2Charset = CharacterSet.US;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> ASCII
+                            currentState.g3Charset = CharacterSet.US;
+                        }
+                    }
+                    break;
+                case 'C':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> FINNISH
+                            currentState.g0Charset = CharacterSet.NRC_FINNISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> FINNISH
+                            currentState.g1Charset = CharacterSet.NRC_FINNISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> FINNISH
+                            currentState.g2Charset = CharacterSet.NRC_FINNISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> FINNISH
+                            currentState.g3Charset = CharacterSet.NRC_FINNISH;
+                        }
+                    }
+                    break;
+                case 'D':
+                    break;
+                case 'E':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> NORWEGIAN
+                            currentState.g0Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> NORWEGIAN
+                            currentState.g1Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> NORWEGIAN
+                            currentState.g2Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> NORWEGIAN
+                            currentState.g3Charset = CharacterSet.NRC_NORWEGIAN;
+                        }
+                    }
+                    break;
+                case 'F':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ' ')) {
+                            // S7C1T
+                            s8c1t = false;
+                        }
+                    }
+                    break;
+                case 'G':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ' ')) {
+                            // S8C1T
+                            s8c1t = true;
+                        }
+                    }
+                    break;
+                case 'H':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> SWEDISH
+                            currentState.g0Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> SWEDISH
+                            currentState.g1Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> SWEDISH
+                            currentState.g2Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> SWEDISH
+                            currentState.g3Charset = CharacterSet.NRC_SWEDISH;
+                        }
+                    }
+                    break;
+                case 'I':
+                case 'J':
+                    break;
+                case 'K':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> GERMAN
+                            currentState.g0Charset = CharacterSet.NRC_GERMAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> GERMAN
+                            currentState.g1Charset = CharacterSet.NRC_GERMAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> GERMAN
+                            currentState.g2Charset = CharacterSet.NRC_GERMAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> GERMAN
+                            currentState.g3Charset = CharacterSet.NRC_GERMAN;
+                        }
+                    }
+                    break;
+                case 'L':
+                case 'M':
+                case 'N':
+                case 'O':
+                case 'P':
+                    break;
+                case 'Q':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> FRENCH_CA
+                            currentState.g0Charset = CharacterSet.NRC_FRENCH_CA;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> FRENCH_CA
+                            currentState.g1Charset = CharacterSet.NRC_FRENCH_CA;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> FRENCH_CA
+                            currentState.g2Charset = CharacterSet.NRC_FRENCH_CA;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> FRENCH_CA
+                            currentState.g3Charset = CharacterSet.NRC_FRENCH_CA;
+                        }
+                    }
+                    break;
+                case 'R':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> FRENCH
+                            currentState.g0Charset = CharacterSet.NRC_FRENCH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> FRENCH
+                            currentState.g1Charset = CharacterSet.NRC_FRENCH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> FRENCH
+                            currentState.g2Charset = CharacterSet.NRC_FRENCH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> FRENCH
+                            currentState.g3Charset = CharacterSet.NRC_FRENCH;
+                        }
+                    }
+                    break;
+                case 'S':
+                case 'T':
+                case 'U':
+                case 'V':
+                case 'W':
+                case 'X':
+                    break;
+                case 'Y':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> ITALIAN
+                            currentState.g0Charset = CharacterSet.NRC_ITALIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> ITALIAN
+                            currentState.g1Charset = CharacterSet.NRC_ITALIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> ITALIAN
+                            currentState.g2Charset = CharacterSet.NRC_ITALIAN;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> ITALIAN
+                            currentState.g3Charset = CharacterSet.NRC_ITALIAN;
+                        }
+                    }
+                    break;
+                case 'Z':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '(')) {
+                            // G0 --> SPANISH
+                            currentState.g0Charset = CharacterSet.NRC_SPANISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == ')')) {
+                            // G1 --> SPANISH
+                            currentState.g1Charset = CharacterSet.NRC_SPANISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '*')) {
+                            // G2 --> SPANISH
+                            currentState.g2Charset = CharacterSet.NRC_SPANISH;
+                        }
+                        if ((collectBuffer.length() == 1)
+                            && (collectBuffer.charAt(0) == '+')) {
+                            // G3 --> SPANISH
+                            currentState.g3Charset = CharacterSet.NRC_SPANISH;
+                        }
+                    }
+                    break;
+                case '[':
+                case '\\':
+                case ']':
+                case '^':
+                case '_':
+                case '`':
+                case 'a':
+                case 'b':
+                case 'c':
+                case 'd':
+                case 'e':
+                case 'f':
+                case 'g':
+                case 'h':
+                case 'i':
+                case 'j':
+                case 'k':
+                case 'l':
+                case 'm':
+                case 'n':
+                case 'o':
+                case 'p':
+                case 'q':
+                case 'r':
+                case 's':
+                case 't':
+                case 'u':
+                case 'v':
+                case 'w':
+                case 'x':
+                case 'y':
+                case 'z':
+                case '{':
+                case '|':
+                case '}':
+                case '~':
+                    break;
+                }
+                toGround();
+            }
+
+            // 7F                  --> ignore
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            return;
+
+        case CSI_ENTRY:
+            // 00-17, 19, 1C-1F    --> execute
+            if (ch <= 0x1F) {
+                handleControlChar((char) ch);
+            }
+
+            // 20-2F               --> collect, then switch to CSI_INTERMEDIATE
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+                scanState = ScanState.CSI_INTERMEDIATE;
+            }
+
+            // 30-39, 3B           --> param, then switch to CSI_PARAM
+            if ((ch >= '0') && (ch <= '9')) {
+                param((byte) ch);
+                scanState = ScanState.CSI_PARAM;
+            }
+            if (ch == ';') {
+                param((byte) ch);
+                scanState = ScanState.CSI_PARAM;
+            }
+
+            // 3C-3F               --> collect, then switch to CSI_PARAM
+            if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                collect((char) ch);
+                scanState = ScanState.CSI_PARAM;
+            }
+
+            // 40-7E               --> dispatch, then switch to GROUND
+            if ((ch >= 0x40) && (ch <= 0x7E)) {
+                switch (ch) {
+                case '@':
+                    // ICH - Insert character
+                    ich();
+                    break;
+                case 'A':
+                    // CUU - Cursor up
+                    cuu();
+                    break;
+                case 'B':
+                    // CUD - Cursor down
+                    cud();
+                    break;
+                case 'C':
+                    // CUF - Cursor forward
+                    cuf();
+                    break;
+                case 'D':
+                    // CUB - Cursor backward
+                    cub();
+                    break;
+                case 'E':
+                    // CNL - Cursor down and to column 1
+                    if (type == DeviceType.XTERM) {
+                        cnl();
+                    }
+                    break;
+                case 'F':
+                    // CPL - Cursor up and to column 1
+                    if (type == DeviceType.XTERM) {
+                        cpl();
+                    }
+                    break;
+                case 'G':
+                    // CHA - Cursor to column # in current row
+                    if (type == DeviceType.XTERM) {
+                        cha();
+                    }
+                    break;
+                case 'H':
+                    // CUP - Cursor position
+                    cup();
+                    break;
+                case 'I':
+                    // CHT - Cursor forward X tab stops (default 1)
+                    if (type == DeviceType.XTERM) {
+                        cht();
+                    }
+                    break;
+                case 'J':
+                    // ED - Erase in display
+                    ed();
+                    break;
+                case 'K':
+                    // EL - Erase in line
+                    el();
+                    break;
+                case 'L':
+                    // IL - Insert line
+                    il();
+                    break;
+                case 'M':
+                    // DL - Delete line
+                    dl();
+                    break;
+                case 'N':
+                case 'O':
+                    break;
+                case 'P':
+                    // DCH - Delete character
+                    dch();
+                    break;
+                case 'Q':
+                case 'R':
+                    break;
+                case 'S':
+                    // Scroll up X lines (default 1)
+                    if (type == DeviceType.XTERM) {
+                        boolean xtermPrivateModeFlag = false;
+                        for (int i = 0; i < collectBuffer.length(); i++) {
+                            if (collectBuffer.charAt(i) == '?') {
+                                xtermPrivateModeFlag = true;
+                                break;
+                            }
+                        }
+                        if (xtermPrivateModeFlag) {
+                            xtermSixelQuery();
+                        } else {
+                            su();
+                        }
+                    }
+                    break;
+                case 'T':
+                    // Scroll down X lines (default 1)
+                    if (type == DeviceType.XTERM) {
+                        sd();
+                    }
+                    break;
+                case 'U':
+                case 'V':
+                case 'W':
+                    break;
+                case 'X':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // ECH - Erase character
+                        ech();
+                    }
+                    break;
+                case 'Y':
+                    break;
+                case 'Z':
+                    // CBT - Cursor backward X tab stops (default 1)
+                    if (type == DeviceType.XTERM) {
+                        cbt();
+                    }
+                    break;
+                case '[':
+                case '\\':
+                case ']':
+                case '^':
+                case '_':
+                    break;
+                case '`':
+                    // HPA - Cursor to column # in current row.  Same as CHA
+                    if (type == DeviceType.XTERM) {
+                        cha();
+                    }
+                    break;
+                case 'a':
+                    // HPR - Cursor right.  Same as CUF
+                    if (type == DeviceType.XTERM) {
+                        cuf();
+                    }
+                    break;
+                case 'b':
+                    // REP - Repeat last char X times
+                    if (type == DeviceType.XTERM) {
+                        rep();
+                    }
+                    break;
+                case 'c':
+                    // DA - Device attributes
+                    da();
+                    break;
+                case 'd':
+                    // VPA - Cursor to row, current column.
+                    if (type == DeviceType.XTERM) {
+                        vpa();
+                    }
+                    break;
+                case 'e':
+                    // VPR - Cursor down.  Same as CUD
+                    if (type == DeviceType.XTERM) {
+                        cud();
+                    }
+                    break;
+                case 'f':
+                    // HVP - Horizontal and vertical position
+                    hvp();
+                    break;
+                case 'g':
+                    // TBC - Tabulation clear
+                    tbc();
+                    break;
+                case 'h':
+                    // Sets an ANSI or DEC private toggle
+                    setToggle(true);
+                    break;
+                case 'i':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // Printer functions
+                        printerFunctions();
+                    }
+                    break;
+                case 'j':
+                case 'k':
+                    break;
+                case 'l':
+                    // Sets an ANSI or DEC private toggle
+                    setToggle(false);
+                    break;
+                case 'm':
+                    // SGR - Select graphics rendition
+                    sgr();
+                    break;
+                case 'n':
+                    // DSR - Device status report
+                    dsr();
+                    break;
+                case 'o':
+                case 'p':
+                    break;
+                case 'q':
+                    // DECLL - Load leds
+                    // Not supported
+                    break;
+                case 'r':
+                    // DECSTBM - Set top and bottom margins
+                    decstbm();
+                    break;
+                case 's':
+                    // Save cursor (ANSI.SYS)
+                    if (type == DeviceType.XTERM) {
+                        savedState.cursorX = currentState.cursorX;
+                        savedState.cursorY = currentState.cursorY;
+                    }
+                    break;
+                case 't':
+                    if (type == DeviceType.XTERM) {
+                        // Window operations
+                        xtermWindowOps();
+                    }
+                    break;
+                case 'u':
+                    // Restore cursor (ANSI.SYS)
+                    if (type == DeviceType.XTERM) {
+                        cursorPosition(savedState.cursorY, savedState.cursorX);
+                    }
+                    break;
+                case 'v':
+                case 'w':
+                    break;
+                case 'x':
+                    // DECREQTPARM - Request terminal parameters
+                    decreqtparm();
+                    break;
+                case 'y':
+                case 'z':
+                case '{':
+                case '|':
+                case '}':
+                case '~':
+                    break;
+                }
+                toGround();
+            }
+
+            // 7F                  --> ignore
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            // 0x3A goes to CSI_IGNORE
+            if (ch == 0x3A) {
+                scanState = ScanState.CSI_IGNORE;
+            }
+            return;
+
+        case CSI_PARAM:
+            // 00-17, 19, 1C-1F    --> execute
+            if (ch <= 0x1F) {
+                handleControlChar((char) ch);
+            }
+
+            // 20-2F               --> collect, then switch to CSI_INTERMEDIATE
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+                scanState = ScanState.CSI_INTERMEDIATE;
+            }
+
+            // 30-39, 3B           --> param
+            if ((ch >= '0') && (ch <= '9')) {
+                param((byte) ch);
+            }
+            if (ch == ';') {
+                param((byte) ch);
+            }
+
+            // 0x3A goes to CSI_IGNORE
+            if (ch == 0x3A) {
+                scanState = ScanState.CSI_IGNORE;
+            }
+            // 0x3C-3F goes to CSI_IGNORE
+            if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                scanState = ScanState.CSI_IGNORE;
+            }
+
+            // 40-7E               --> dispatch, then switch to GROUND
+            if ((ch >= 0x40) && (ch <= 0x7E)) {
+                switch (ch) {
+                case '@':
+                    // ICH - Insert character
+                    ich();
+                    break;
+                case 'A':
+                    // CUU - Cursor up
+                    cuu();
+                    break;
+                case 'B':
+                    // CUD - Cursor down
+                    cud();
+                    break;
+                case 'C':
+                    // CUF - Cursor forward
+                    cuf();
+                    break;
+                case 'D':
+                    // CUB - Cursor backward
+                    cub();
+                    break;
+                case 'E':
+                    // CNL - Cursor down and to column 1
+                    if (type == DeviceType.XTERM) {
+                        cnl();
+                    }
+                    break;
+                case 'F':
+                    // CPL - Cursor up and to column 1
+                    if (type == DeviceType.XTERM) {
+                        cpl();
+                    }
+                    break;
+                case 'G':
+                    // CHA - Cursor to column # in current row
+                    if (type == DeviceType.XTERM) {
+                        cha();
+                    }
+                    break;
+                case 'H':
+                    // CUP - Cursor position
+                    cup();
+                    break;
+                case 'I':
+                    // CHT - Cursor forward X tab stops (default 1)
+                    if (type == DeviceType.XTERM) {
+                        cht();
+                    }
+                    break;
+                case 'J':
+                    // ED - Erase in display
+                    ed();
+                    break;
+                case 'K':
+                    // EL - Erase in line
+                    el();
+                    break;
+                case 'L':
+                    // IL - Insert line
+                    il();
+                    break;
+                case 'M':
+                    // DL - Delete line
+                    dl();
+                    break;
+                case 'N':
+                case 'O':
+                    break;
+                case 'P':
+                    // DCH - Delete character
+                    dch();
+                    break;
+                case 'Q':
+                case 'R':
+                    break;
+                case 'S':
+                    // Scroll up X lines (default 1)
+                    if (type == DeviceType.XTERM) {
+                        boolean xtermPrivateModeFlag = false;
+                        for (int i = 0; i < collectBuffer.length(); i++) {
+                            if (collectBuffer.charAt(i) == '?') {
+                                xtermPrivateModeFlag = true;
+                                break;
+                            }
+                        }
+                        if (xtermPrivateModeFlag) {
+                            xtermSixelQuery();
+                        } else {
+                            su();
+                        }
+                    }
+                    break;
+                case 'T':
+                    // Scroll down X lines (default 1)
+                    if (type == DeviceType.XTERM) {
+                        sd();
+                    }
+                    break;
+                case 'U':
+                case 'V':
+                case 'W':
+                    break;
+                case 'X':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // ECH - Erase character
+                        ech();
+                    }
+                    break;
+                case 'Y':
+                    break;
+                case 'Z':
+                    // CBT - Cursor backward X tab stops (default 1)
+                    if (type == DeviceType.XTERM) {
+                        cbt();
+                    }
+                    break;
+                case '[':
+                case '\\':
+                case ']':
+                case '^':
+                case '_':
+                    break;
+                case '`':
+                    // HPA - Cursor to column # in current row.  Same as CHA
+                    if (type == DeviceType.XTERM) {
+                        cha();
+                    }
+                    break;
+                case 'a':
+                    // HPR - Cursor right.  Same as CUF
+                    if (type == DeviceType.XTERM) {
+                        cuf();
+                    }
+                    break;
+                case 'b':
+                    // REP - Repeat last char X times
+                    if (type == DeviceType.XTERM) {
+                        rep();
+                    }
+                    break;
+                case 'c':
+                    // DA - Device attributes
+                    da();
+                    break;
+                case 'd':
+                    // VPA - Cursor to row, current column.
+                    if (type == DeviceType.XTERM) {
+                        vpa();
+                    }
+                    break;
+                case 'e':
+                    // VPR - Cursor down.  Same as CUD
+                    if (type == DeviceType.XTERM) {
+                        cud();
+                    }
+                    break;
+                case 'f':
+                    // HVP - Horizontal and vertical position
+                    hvp();
+                    break;
+                case 'g':
+                    // TBC - Tabulation clear
+                    tbc();
+                    break;
+                case 'h':
+                    // Sets an ANSI or DEC private toggle
+                    setToggle(true);
+                    break;
+                case 'i':
+                    if ((type == DeviceType.VT220)
+                        || (type == DeviceType.XTERM)) {
+
+                        // Printer functions
+                        printerFunctions();
+                    }
+                    break;
+                case 'j':
+                case 'k':
+                    break;
+                case 'l':
+                    // Sets an ANSI or DEC private toggle
+                    setToggle(false);
+                    break;
+                case 'm':
+                    // SGR - Select graphics rendition
+                    sgr();
+                    break;
+                case 'n':
+                    // DSR - Device status report
+                    dsr();
+                    break;
+                case 'o':
+                case 'p':
+                    break;
+                case 'q':
+                    // DECLL - Load leds
+                    // Not supported
+                    break;
+                case 'r':
+                    // DECSTBM - Set top and bottom margins
+                    decstbm();
+                    break;
+                case 's':
+                    break;
+                case 't':
+                    if (type == DeviceType.XTERM) {
+                        // Window operations
+                        xtermWindowOps();
+                    }
+                    break;
+                case 'u':
+                case 'v':
+                case 'w':
+                    break;
+                case 'x':
+                    // DECREQTPARM - Request terminal parameters
+                    decreqtparm();
+                    break;
+                case 'y':
+                case 'z':
+                case '{':
+                case '|':
+                case '}':
+                case '~':
+                    break;
+                }
+                toGround();
+            }
+
+            // 7F                  --> ignore
+            return;
+
+        case CSI_INTERMEDIATE:
+            // 00-17, 19, 1C-1F    --> execute
+            if (ch <= 0x1F) {
+                handleControlChar((char) ch);
+            }
+
+            // 20-2F               --> collect
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+            }
+
+            // 0x30-3F goes to CSI_IGNORE
+            if ((ch >= 0x30) && (ch <= 0x3F)) {
+                scanState = ScanState.CSI_IGNORE;
+            }
+
+            // 40-7E               --> dispatch, then switch to GROUND
+            if ((ch >= 0x40) && (ch <= 0x7E)) {
+                switch (ch) {
+                case '@':
+                case 'A':
+                case 'B':
+                case 'C':
+                case 'D':
+                case 'E':
+                case 'F':
+                case 'G':
+                case 'H':
+                case 'I':
+                case 'J':
+                case 'K':
+                case 'L':
+                case 'M':
+                case 'N':
+                case 'O':
+                case 'P':
+                case 'Q':
+                case 'R':
+                case 'S':
+                case 'T':
+                case 'U':
+                case 'V':
+                case 'W':
+                case 'X':
+                case 'Y':
+                case 'Z':
+                case '[':
+                case '\\':
+                case ']':
+                case '^':
+                case '_':
+                case '`':
+                case 'a':
+                case 'b':
+                case 'c':
+                case 'd':
+                case 'e':
+                case 'f':
+                case 'g':
+                case 'h':
+                case 'i':
+                case 'j':
+                case 'k':
+                case 'l':
+                case 'm':
+                case 'n':
+                case 'o':
+                    break;
+                case 'p':
+                    if (((type == DeviceType.VT220)
+                            || (type == DeviceType.XTERM))
+                        && (collectBuffer.charAt(collectBuffer.length() - 1) == '\"')
+                    ) {
+                        // DECSCL - compatibility level
+                        decscl();
+                    }
+                    if ((type == DeviceType.XTERM)
+                        && (collectBuffer.charAt(collectBuffer.length() - 1) == '!')
+                    ) {
+                        // DECSTR - Soft terminal reset
+                        decstr();
+                    }
+                    break;
+                case 'q':
+                    if (((type == DeviceType.VT220)
+                            || (type == DeviceType.XTERM))
+                        && (collectBuffer.charAt(collectBuffer.length() - 1) == '\"')
+                    ) {
+                        // DECSCA
+                        decsca();
+                    }
+                    break;
+                case 'r':
+                case 's':
+                case 't':
+                case 'u':
+                case 'v':
+                case 'w':
+                case 'x':
+                case 'y':
+                case 'z':
+                case '{':
+                case '|':
+                case '}':
+                case '~':
+                    break;
+                }
+                toGround();
+            }
+
+            // 7F                  --> ignore
+            return;
+
+        case CSI_IGNORE:
+            // 00-17, 19, 1C-1F    --> execute
+            if (ch <= 0x1F) {
+                handleControlChar((char) ch);
+            }
+
+            // 20-2F               --> collect
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+            }
+
+            // 40-7E               --> ignore, then switch to GROUND
+            if ((ch >= 0x40) && (ch <= 0x7E)) {
+                toGround();
+            }
+
+            // 20-3F, 7F           --> ignore
+
+            return;
+
+        case DCS_ENTRY:
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            // 0x1B 0x5C goes to GROUND
+            if (ch == 0x1B) {
+                collect((char) ch);
+            }
+            if (ch == 0x5C) {
+                if ((collectBuffer.length() > 0)
+                    && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                ) {
+                    toGround();
+                }
+            }
+
+            // 20-2F               --> collect, then switch to DCS_INTERMEDIATE
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+                scanState = ScanState.DCS_INTERMEDIATE;
+            }
+
+            // 30-39, 3B           --> param, then switch to DCS_PARAM
+            if ((ch >= '0') && (ch <= '9')) {
+                param((byte) ch);
+                scanState = ScanState.DCS_PARAM;
+            }
+            if (ch == ';') {
+                param((byte) ch);
+                scanState = ScanState.DCS_PARAM;
+            }
+
+            // 3C-3F               --> collect, then switch to DCS_PARAM
+            if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                collect((char) ch);
+                scanState = ScanState.DCS_PARAM;
+            }
+
+            // 00-17, 19, 1C-1F, 7F    --> ignore
+
+            // 0x3A goes to DCS_IGNORE
+            if (ch == 0x3F) {
+                scanState = ScanState.DCS_IGNORE;
+            }
+
+            // 0x71 goes to DCS_SIXEL
+            if (ch == 0x71) {
+                sixelParseBuffer.setLength(0);
+                scanState = ScanState.DCS_SIXEL;
+            } else if ((ch >= 0x40) && (ch <= 0x7E)) {
+                // 0x40-7E goes to DCS_PASSTHROUGH
+                scanState = ScanState.DCS_PASSTHROUGH;
+            }
+            return;
+
+        case DCS_INTERMEDIATE:
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            // 0x1B 0x5C goes to GROUND
+            if (ch == 0x1B) {
+                collect((char) ch);
+            }
+            if (ch == 0x5C) {
+                if ((collectBuffer.length() > 0)
+                    && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                ) {
+                    toGround();
+                }
+            }
+
+            // 0x30-3F goes to DCS_IGNORE
+            if ((ch >= 0x30) && (ch <= 0x3F)) {
+                scanState = ScanState.DCS_IGNORE;
+            }
+
+            // 0x40-7E goes to DCS_PASSTHROUGH
+            if ((ch >= 0x40) && (ch <= 0x7E)) {
+                scanState = ScanState.DCS_PASSTHROUGH;
+            }
+
+            // 00-17, 19, 1C-1F, 7F    --> ignore
+            return;
+
+        case DCS_PARAM:
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            // 0x1B 0x5C goes to GROUND
+            if (ch == 0x1B) {
+                collect((char) ch);
+            }
+            if (ch == 0x5C) {
+                if ((collectBuffer.length() > 0)
+                    && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                ) {
+                    toGround();
+                }
+            }
+
+            // 20-2F          --> collect, then switch to DCS_INTERMEDIATE
+            if ((ch >= 0x20) && (ch <= 0x2F)) {
+                collect((char) ch);
+                scanState = ScanState.DCS_INTERMEDIATE;
+            }
+
+            // 30-39, 3B      --> param
+            if ((ch >= '0') && (ch <= '9')) {
+                param((byte) ch);
+            }
+            if (ch == ';') {
+                param((byte) ch);
+            }
+
+            // 00-17, 19, 1C-1F, 7F    --> ignore
+
+            // 0x3A, 3C-3F goes to DCS_IGNORE
+            if (ch == 0x3F) {
+                scanState = ScanState.DCS_IGNORE;
+            }
+            if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                scanState = ScanState.DCS_IGNORE;
+            }
+
+            // 0x71 goes to DCS_SIXEL
+            if (ch == 0x71) {
+                sixelParseBuffer.setLength(0);
+                scanState = ScanState.DCS_SIXEL;
+            } else if ((ch >= 0x40) && (ch <= 0x7E)) {
+                // 0x40-7E goes to DCS_PASSTHROUGH
+                scanState = ScanState.DCS_PASSTHROUGH;
+            }
+            return;
+
+        case DCS_PASSTHROUGH:
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            // 0x1B 0x5C goes to GROUND
+            if (ch == 0x1B) {
+                collect((char) ch);
+            }
+            if (ch == 0x5C) {
+                if ((collectBuffer.length() > 0)
+                    && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                ) {
+                    toGround();
+                }
+            }
+
+            // 00-17, 19, 1C-1F, 20-7E   --> put
+            if (ch <= 0x17) {
+                // We ignore all DCS except sixel.
+                return;
+            }
+            if (ch == 0x19) {
+                // We ignore all DCS except sixel.
+                return;
+            }
+            if ((ch >= 0x1C) && (ch <= 0x1F)) {
+                // We ignore all DCS except sixel.
+                return;
+            }
+            if ((ch >= 0x20) && (ch <= 0x7E)) {
+                // We ignore all DCS except sixel.
+                return;
+            }
+
+            // 7F                        --> ignore
+
+            return;
+
+        case DCS_IGNORE:
+            // 00-17, 19, 1C-1F, 20-7F --> ignore
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            return;
+
+        case DCS_SIXEL:
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                parseSixel();
+                toGround();
+                return;
+            }
+
+            // 0x1B 0x5C goes to GROUND
+            if (ch == 0x1B) {
+                collect((char) ch);
+                return;
+            }
+            if (ch == 0x5C) {
+                if ((collectBuffer.length() > 0)
+                    && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                ) {
+                    parseSixel();
+                    toGround();
+                    return;
+                }
+            }
+
+            // 00-17, 19, 1C-1F, 20-7E   --> put
+            if ((ch <= 0x17)
+                || (ch == 0x19)
+                || ((ch >= 0x1C) && (ch <= 0x1F))
+                || ((ch >= 0x20) && (ch <= 0x7E))
+            ) {
+                sixelParseBuffer.append((char) ch);
+            }
+
+            // 7F                        --> ignore
+            return;
+
+        case SOSPMAPC_STRING:
+            // 00-17, 19, 1C-1F, 20-7F --> ignore
+
+            // Special case for Jexer: PM can pass one control character
+            if (ch == 0x1B) {
+                pmPut((char) ch);
+            }
+
+            if ((ch >= 0x20) && (ch <= 0x7F)) {
+                pmPut((char) ch);
+            }
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            return;
+
+        case OSC_STRING:
+            // Special case for Xterm: OSC can pass control characters
+            if ((ch == 0x9C) || (ch == 0x07) || (ch == 0x1B)) {
+                oscPut((char) ch);
+            }
+
+            // 00-17, 19, 1C-1F        --> ignore
+
+            // 20-7F                   --> osc_put
+            if ((ch >= 0x20) && (ch <= 0x7F)) {
+                oscPut((char) ch);
+            }
+
+            // 0x9C goes to GROUND
+            if (ch == 0x9C) {
+                toGround();
+            }
+
+            return;
+
+        case VT52_DIRECT_CURSOR_ADDRESS:
+            // This is a special case for the VT52 sequence "ESC Y l c"
+            if (collectBuffer.length() == 0) {
+                collect((char) ch);
+            } else if (collectBuffer.length() == 1) {
+                // We've got the two characters, one in the buffer and the
+                // other in ch.
+                cursorPosition(collectBuffer.charAt(0) - '\040', ch - '\040');
+                toGround();
+            }
+            return;
+        }
+
+    }
+
+    /**
+     * Expose current cursor X to outside world.
+     *
+     * @return current cursor X
+     */
+    public final int getCursorX() {
+        if (display.get(currentState.cursorY).isDoubleWidth()) {
+            return currentState.cursorX * 2;
+        }
+        return currentState.cursorX;
+    }
+
+    /**
+     * Expose current cursor Y to outside world.
+     *
+     * @return current cursor Y
+     */
+    public final int getCursorY() {
+        return currentState.cursorY;
+    }
+
+    /**
+     * Returns true if this terminal has requested the mouse pointer be
+     * hidden.
+     *
+     * @return true if this terminal has requested the mouse pointer be
+     * hidden
+     */
+    public final boolean hasHiddenMousePointer() {
+        return hideMousePointer;
+    }
+
+    /**
+     * Get the mouse protocol.
+     *
+     * @return MouseProtocol.OFF, MouseProtocol.X10, etc.
+     */
+    public MouseProtocol getMouseProtocol() {
+        return mouseProtocol;
+    }
+
+    /**
+     * Draw the left and right cells of a two-cell-wide (full-width) glyph.
+     *
+     * @param leftX the x position to draw the left half to
+     * @param leftY the y position to draw the left half to
+     * @param rightX the x position to draw the right half to
+     * @param rightY the y position to draw the right half to
+     * @param ch the character to draw
+     */
+    private void drawHalves(final int leftX, final int leftY,
+        final int rightX, final int rightY, final int ch) {
+
+        // System.err.println("drawHalves(): " + Integer.toHexString(ch));
+
+        if (lastTextHeight != textHeight) {
+            glyphMaker = GlyphMaker.getInstance(textHeight);
+            lastTextHeight = textHeight;
+        }
+
+        Cell cell = new Cell(ch, currentState.attr);
+        BufferedImage image = glyphMaker.getImage(cell, textWidth * 2,
+            textHeight);
+        BufferedImage leftImage = image.getSubimage(0, 0, textWidth,
+            textHeight);
+        BufferedImage rightImage = image.getSubimage(textWidth, 0, textWidth,
+            textHeight);
+
+        Cell left = new Cell(cell);
+        left.setImage(leftImage);
+        left.setWidth(Cell.Width.LEFT);
+        display.get(leftY).replace(leftX, left);
+
+        Cell right = new Cell(cell);
+        right.setImage(rightImage);
+        right.setWidth(Cell.Width.RIGHT);
+        display.get(rightY).replace(rightX, right);
+    }
+
+    /**
+     * Set the width of a character cell in pixels.
+     *
+     * @param textWidth the width in pixels of a character cell
+     */
+    public void setTextWidth(final int textWidth) {
+        this.textWidth = textWidth;
+    }
+
+    /**
+     * Set the height of a character cell in pixels.
+     *
+     * @param textHeight the height in pixels of a character cell
+     */
+    public void setTextHeight(final int textHeight) {
+        this.textHeight = textHeight;
+    }
+
+    /**
+     * Parse a sixel string into a bitmap image, and overlay that image onto
+     * the text cells.
+     */
+    private void parseSixel() {
+
+        /*
+        System.err.println("parseSixel(): '" + sixelParseBuffer.toString()
+            + "'");
+        */
+
+        Sixel sixel = new Sixel(sixelParseBuffer.toString(), sixelPalette);
+        BufferedImage image = sixel.getImage();
+
+        // System.err.println("parseSixel(): image " + image);
+
+        if (image == null) {
+            // Sixel data was malformed in some way, bail out.
+            return;
+        }
+        if ((image.getWidth() < 1)
+            || (image.getWidth() > 10000)
+            || (image.getHeight() < 1)
+            || (image.getHeight() > 10000)
+        ) {
+            return;
+        }
+
+        imageToCells(image, true);
+    }
+
+    /**
+     * Parse a "Jexer" RGB image string into a bitmap image, and overlay that
+     * image onto the text cells.
+     *
+     * @param pw width token
+     * @param ph height token
+     * @param ps scroll token
+     * @param data pixel data
+     */
+    private void parseJexerImageRGB(final String pw, final String ph,
+        final String ps, final String data) {
+
+        int imageWidth = 0;
+        int imageHeight = 0;
+        boolean scroll = false;
+        try {
+            imageWidth = Integer.parseInt(pw);
+            imageHeight = Integer.parseInt(ph);
+        } catch (NumberFormatException e) {
+            // SQUASH
+            return;
+        }
+        if ((imageWidth < 1)
+            || (imageWidth > 10000)
+            || (imageHeight < 1)
+            || (imageHeight > 10000)
+        ) {
+            return;
+        }
+        if (ps.equals("1")) {
+            scroll = true;
+        } else if (ps.equals("0")) {
+            scroll = false;
+        } else {
+            return;
+        }
+
+        byte [] bytes = StringUtils.fromBase64(data.getBytes());
+        if (bytes.length != (imageWidth * imageHeight * 3)) {
+            return;
+        }
+
+        BufferedImage image = new BufferedImage(imageWidth, imageHeight,
+            BufferedImage.TYPE_INT_ARGB);
+
+        for (int x = 0; x < imageWidth; x++) {
+            for (int y = 0; y < imageHeight; y++) {
+                int red   = bytes[(y * imageWidth * 3) + (x * 3)    ];
+                if (red < 0) {
+                    red += 256;
+                }
+                int green = bytes[(y * imageWidth * 3) + (x * 3) + 1];
+                if (green < 0) {
+                    green += 256;
+                }
+                int blue  = bytes[(y * imageWidth * 3) + (x * 3) + 2];
+                if (blue < 0) {
+                    blue += 256;
+                }
+                int rgb = 0xFF000000 | (red << 16) | (green << 8) | blue;
+                image.setRGB(x, y, rgb);
+            }
+        }
+
+        imageToCells(image, scroll);
+    }
+
+    /**
+     * Parse a "Jexer" PNG or JPG image string into a bitmap image, and
+     * overlay that image onto the text cells.
+     *
+     * @param type 1 for PNG, 2 for JPG
+     * @param ps scroll token
+     * @param data pixel data
+     */
+    private void parseJexerImageFile(final int type, final String ps,
+        final String data) {
+
+        int imageWidth = 0;
+        int imageHeight = 0;
+        boolean scroll = false;
+        BufferedImage image = null;
+        try {
+            byte [] bytes = StringUtils.fromBase64(data.getBytes());
+
+            switch (type) {
+            case 1:
+                if ((bytes[0] != (byte) 0x89)
+                    || (bytes[1] != 'P')
+                    || (bytes[2] != 'N')
+                    || (bytes[3] != 'G')
+                    || (bytes[4] != (byte) 0x0D)
+                    || (bytes[5] != (byte) 0x0A)
+                    || (bytes[6] != (byte) 0x1A)
+                    || (bytes[7] != (byte) 0x0A)
+                ) {
+                    // File does not have PNG header, bail out.
+                    return;
+                }
+                break;
+
+            case 2:
+                if ((bytes[0] != (byte) 0XFF)
+                    || (bytes[1] != (byte) 0xD8)
+                    || (bytes[2] != (byte) 0xFF)
+                ) {
+                    // File does not have JPG header, bail out.
+                    return;
+                }
+                break;
+
+            default:
+                // Unsupported type, bail out.
+                return;
+            }
+
+            image = ImageIO.read(new ByteArrayInputStream(bytes));
+        } catch (IOException e) {
+            // SQUASH
+            return;
+        }
+        assert (image != null);
+        imageWidth = image.getWidth();
+        imageHeight = image.getHeight();
+        if ((imageWidth < 1)
+            || (imageWidth > 10000)
+            || (imageHeight < 1)
+            || (imageHeight > 10000)
+        ) {
+            return;
+        }
+        if (ps.equals("1")) {
+            scroll = true;
+        } else if (ps.equals("0")) {
+            scroll = false;
+        } else {
+            return;
+        }
+
+        imageToCells(image, scroll);
+    }
+
+    /**
+     * Break up an image into the cells at the current cursor.
+     *
+     * @param image the image to display
+     * @param scroll if true, scroll the image and move the cursor
+     */
+    private void imageToCells(final BufferedImage image, final boolean scroll) {
+        assert (image != null);
+
+        /*
+         * Procedure:
+         *
+         * Break up the image into text cell sized pieces as a new array of
+         * Cells.
+         *
+         * Note original column position x0.
+         *
+         * For each cell:
+         *
+         * 1. Advance (printCharacter(' ')) for horizontal increment, or
+         *    index (linefeed() + cursorPosition(y, x0)) for vertical
+         *    increment.
+         *
+         * 2. Set (x, y) cell image data.
+         *
+         * 3. For the right and bottom edges:
+         *
+         *   a. Render the text to pixels using Terminus font.
+         *
+         *   b. Blit the image on top of the text, using alpha channel.
+         */
+        int cellColumns = image.getWidth() / textWidth;
+        if (cellColumns * textWidth < image.getWidth()) {
+            cellColumns++;
+        }
+        int cellRows = image.getHeight() / textHeight;
+        if (cellRows * textHeight < image.getHeight()) {
+            cellRows++;
+        }
+
+        // Break the image up into an array of cells.
+        Cell [][] cells = new Cell[cellColumns][cellRows];
+
+        for (int x = 0; x < cellColumns; x++) {
+            for (int y = 0; y < cellRows; y++) {
+
+                int width = textWidth;
+                if ((x + 1) * textWidth > image.getWidth()) {
+                    width = image.getWidth() - (x * textWidth);
+                }
+                int height = textHeight;
+                if ((y + 1) * textHeight > image.getHeight()) {
+                    height = image.getHeight() - (y * textHeight);
+                }
+
+                Cell cell = new Cell();
+                if ((width != textWidth) || (height != textHeight)) {
+                    BufferedImage newImage;
+                    newImage = new BufferedImage(textWidth, textHeight,
+                        BufferedImage.TYPE_INT_ARGB);
+
+                    Graphics gr = newImage.getGraphics();
+                    gr.drawImage(image.getSubimage(x * textWidth,
+                            y * textHeight, width, height),
+                        0, 0, null, null);
+                    gr.dispose();
+                    cell.setImage(newImage);
+                } else {
+                    cell.setImage(image.getSubimage(x * textWidth,
+                            y * textHeight, width, height));
+                }
+
+                cells[x][y] = cell;
+            }
+        }
+
+        int x0 = currentState.cursorX;
+        int y0 = currentState.cursorY;
+        for (int y = 0; y < cellRows; y++) {
+            for (int x = 0; x < cellColumns; x++) {
+                assert (currentState.cursorX <= rightMargin);
+
+                // A real sixel terminal would render the text of the current
+                // cell first, then image over it (accounting for blank
+                // pixels).  We do not support that.  A cell is either text,
+                // or image, but not a mix of image-over-text.
+                DisplayLine line = display.get(currentState.cursorY);
+                line.replace(currentState.cursorX, cells[x][y]);
+
+                // If at the end of the visible screen, stop.
+                if (currentState.cursorX == rightMargin) {
+                    break;
+                }
+                // Room for more image on the visible screen.
+                currentState.cursorX++;
+            }
+            if (currentState.cursorY < scrollRegionBottom - 1) {
+                // Not at the bottom, down a line.
+                linefeed();
+            } else if (scroll == true) {
+                // At the bottom, scroll as needed.
+                linefeed();
+            } else {
+                // At the bottom, no more scrolling, done.
+                break;
+            }
+
+            cursorPosition(currentState.cursorY, x0);
+        }
+
+        if (scroll == false) {
+            cursorPosition(y0, x0);
+        }
+
+    }
+
+}
diff --git a/src/jexer/tterminal/Sixel.java b/src/jexer/tterminal/Sixel.java
new file mode 100644 (file)
index 0000000..b91e77a
--- /dev/null
@@ -0,0 +1,588 @@
+/*
+ * 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.tterminal;
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.util.HashMap;
+
+/**
+ * Sixel parses a buffer of sixel image data into a BufferedImage.
+ */
+public class Sixel {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Parser character scan states.
+     */
+    private enum ScanState {
+        GROUND,
+        RASTER,
+        COLOR,
+        REPEAT,
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * If true, enable debug messages.
+     */
+    private static boolean DEBUG = false;
+
+    /**
+     * Number of pixels to increment when we need more horizontal room.
+     */
+    private static int WIDTH_INCREASE = 400;
+
+    /**
+     * Number of pixels to increment when we need more vertical room.
+     */
+    private static int HEIGHT_INCREASE = 400;
+
+    /**
+     * Maximum width in pixels.
+     */
+    private static int MAX_WIDTH = 1000;
+
+    /**
+     * Maximum height in pixels.
+     */
+    private static int MAX_HEIGHT = 1000;
+
+    /**
+     * Current scanning state.
+     */
+    private ScanState scanState = ScanState.GROUND;
+
+    /**
+     * Parameters being collected.
+     */
+    private int [] params = new int[5];
+
+    /**
+     * Current parameter being collected.
+     */
+    private int paramsI = 0;
+
+    /**
+     * The sixel palette colors specified.
+     */
+    private HashMap<Integer, Color> palette;
+
+    /**
+     * The buffer to parse.
+     */
+    private String buffer;
+
+    /**
+     * The image being drawn to.
+     */
+    private BufferedImage image;
+
+    /**
+     * The real width of image.
+     */
+    private int width = 0;
+
+    /**
+     * The real height of image.
+     */
+    private int height = 0;
+
+    /**
+     * The width of image provided in the raster attribute.
+     */
+    private int rasterWidth = 0;
+
+    /**
+     * The height of image provided in the raster attribute.
+     */
+    private int rasterHeight = 0;
+
+    /**
+     * The repeat count.
+     */
+    private int repeatCount = -1;
+
+    /**
+     * The current drawing x position.
+     */
+    private int x = 0;
+
+    /**
+     * The maximum y drawn to.  This will set the final image height.
+     */
+    private int y = 0;
+
+    /**
+     * The current drawing color.
+     */
+    private Color color = Color.BLACK;
+
+    /**
+     * If set, abort processing this image.
+     */
+    private boolean abort = false;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param buffer the sixel data to parse
+     * @param palette palette to use, or null for a private palette
+     */
+    public Sixel(final String buffer, final HashMap<Integer, Color> palette) {
+        this.buffer = buffer;
+        if (palette == null) {
+            this.palette = new HashMap<Integer, Color>();
+        } else {
+            this.palette = palette;
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Sixel ------------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the image.
+     *
+     * @return the sixel data as an image.
+     */
+    public BufferedImage getImage() {
+        if (buffer != null) {
+            for (int i = 0; (i < buffer.length()) && (abort == false); i++) {
+                consume(buffer.charAt(i));
+            }
+            buffer = null;
+        }
+        if (abort == true) {
+            return null;
+        }
+
+        if ((width > 0) && (height > 0) && (image != null)) {
+            /*
+            System.err.println(String.format("%d %d %d %d", width, y + 1,
+                    rasterWidth, rasterHeight));
+            */
+
+            if ((rasterWidth > width) || (rasterHeight > y + 1)) {
+                resizeImage(Math.max(width, rasterWidth),
+                    Math.max(y + 1, rasterHeight));
+            }
+            return image.getSubimage(0, 0, width, y + 1);
+        }
+        return null;
+    }
+
+    /**
+     * Resize image to a new size.
+     *
+     * @param newWidth new width of image
+     * @param newHeight new height of image
+     */
+    private void resizeImage(final int newWidth, final int newHeight) {
+        BufferedImage newImage = new BufferedImage(newWidth, newHeight,
+            BufferedImage.TYPE_INT_ARGB);
+
+        if (image == null) {
+            image = newImage;
+            return;
+        }
+
+        if (DEBUG) {
+            System.err.println("resizeImage(); old " + image.getWidth() + "x" +
+                image.getHeight() + " new " + newWidth + "x" + newHeight);
+        }
+
+        Graphics2D gr = newImage.createGraphics();
+        gr.drawImage(image, 0, 0, image.getWidth(), image.getHeight(), null);
+        gr.dispose();
+        image = newImage;
+    }
+
+    /**
+     * Clear the parameters and flags.
+     */
+    private void toGround() {
+        paramsI = 0;
+        for (int i = 0; i < params.length; i++) {
+            params[i] = 0;
+        }
+        scanState = ScanState.GROUND;
+        repeatCount = -1;
+    }
+
+    /**
+     * Get a color parameter value, with a default.
+     *
+     * @param position parameter index.  0 is the first parameter.
+     * @param defaultValue value to use if colorParams[position] doesn't exist
+     * @return parameter value
+     */
+    private int getParam(final int position, final int defaultValue) {
+        if (position > paramsI) {
+            return defaultValue;
+        }
+        return params[position];
+    }
+
+    /**
+     * Get a color parameter value, clamped to within min/max.
+     *
+     * @param position parameter index.  0 is the first parameter.
+     * @param defaultValue value to use if colorParams[position] doesn't exist
+     * @param minValue minimum value inclusive
+     * @param maxValue maximum value inclusive
+     * @return parameter value
+     */
+    private int getParam(final int position, final int defaultValue,
+        final int minValue, final int maxValue) {
+
+        assert (minValue <= maxValue);
+        int value = getParam(position, defaultValue);
+        if (value < minValue) {
+            value = minValue;
+        }
+        if (value > maxValue) {
+            value = maxValue;
+        }
+        return value;
+    }
+
+    /**
+     * Add sixel data to the image.
+     *
+     * @param ch the character of sixel data
+     */
+    private void addSixel(final char ch) {
+        int n = ((int) ch - 63);
+
+        if (DEBUG && (color == null)) {
+            System.err.println("color is null?!");
+            System.err.println(buffer);
+        }
+
+        int rgb = color.getRGB();
+        int rep = (repeatCount == -1 ? 1 : repeatCount);
+
+        if (DEBUG) {
+            System.err.println("addSixel() rep " + rep + " char " +
+                Integer.toHexString(n) + " color " + color);
+        }
+
+        assert (n >= 0);
+
+        if (image == null) {
+            // The raster attributes was not provided.
+            resizeImage(WIDTH_INCREASE, HEIGHT_INCREASE);
+        }
+
+        if (x + rep > image.getWidth()) {
+            // Resize the image, give us another max(rep, WIDTH_INCREASE)
+            // pixels of horizontal length.
+            resizeImage(image.getWidth() + Math.max(rep, WIDTH_INCREASE),
+                image.getHeight());
+        }
+
+        // If nothing will be drawn, just advance x.
+        if (n == 0) {
+            x += rep;
+            if (x > width) {
+                width = x;
+            }
+            if (width > MAX_WIDTH) {
+                abort = true;
+            }
+            return;
+        }
+
+        int dy = 0;
+        for (int i = 0; i < rep; i++) {
+            if ((n & 0x01) != 0) {
+                dy = 0;
+                image.setRGB(x, height + dy, rgb);
+            }
+            if ((n & 0x02) != 0) {
+                dy = 1;
+                image.setRGB(x, height + dy, rgb);
+            }
+            if ((n & 0x04) != 0) {
+                dy = 2;
+                image.setRGB(x, height + dy, rgb);
+            }
+            if ((n & 0x08) != 0) {
+                dy = 3;
+                image.setRGB(x, height + dy, rgb);
+            }
+            if ((n & 0x10) != 0) {
+                dy = 4;
+                image.setRGB(x, height + dy, rgb);
+            }
+            if ((n & 0x20) != 0) {
+                dy = 5;
+                image.setRGB(x, height + dy, rgb);
+            }
+            if (height + dy > y) {
+                y = height + dy;
+            }
+            x++;
+        }
+        if (x > width) {
+            width = x;
+        }
+        if (width > MAX_WIDTH) {
+            abort = true;
+        }
+        if (y + 1 > MAX_HEIGHT) {
+            abort = true;
+        }
+    }
+
+    /**
+     * Process a color palette change.
+     */
+    private void setPalette() {
+        int idx = getParam(0, 0);
+
+        if (paramsI == 0) {
+            Color newColor = palette.get(idx);
+            if (newColor != null) {
+                color = newColor;
+            } else {
+                if (DEBUG) {
+                    System.err.println("COLOR " + idx + " NOT FOUND");
+                }
+                color = Color.BLACK;
+            }
+
+            if (DEBUG) {
+                System.err.println("set color " + idx + " " + color);
+            }
+            return;
+        }
+
+        int type = getParam(1, 0);
+        float red   = (float) (getParam(2, 0, 0, 100) / 100.0);
+        float green = (float) (getParam(3, 0, 0, 100) / 100.0);
+        float blue  = (float) (getParam(4, 0, 0, 100) / 100.0);
+
+        if (type == 2) {
+            Color newColor = new Color(red, green, blue);
+            palette.put(idx, newColor);
+            if (DEBUG) {
+                System.err.println("Palette color " + idx + " --> " + newColor);
+            }
+        } else {
+            if (DEBUG) {
+                System.err.println("UNKNOWN COLOR TYPE " + type + ": " + type +
+                    " " + idx + " R " + red + " G " + green + " B " + blue);
+            }
+        }
+    }
+
+    /**
+     * Parse the raster attributes.
+     */
+    private void parseRaster() {
+        int pan = getParam(0, 0);  // Aspect ratio numerator
+        int pad = getParam(1, 0);  // Aspect ratio denominator
+        int pah = getParam(2, 0);  // Horizontal width
+        int pav = getParam(3, 0);  // Vertical height
+
+        if ((pan == pad) && (pah > 0) && (pav > 0)) {
+            rasterWidth = pah;
+            rasterHeight = pav;
+            if ((rasterWidth <= MAX_WIDTH) && (rasterHeight <= MAX_HEIGHT)) {
+                resizeImage(rasterWidth, rasterHeight);
+            } else {
+                abort = true;
+            }
+        } else {
+            abort = true;
+        }
+    }
+
+    /**
+     * Run this input character through the sixel state machine.
+     *
+     * @param ch character from the remote side
+     */
+    private void consume(char ch) {
+
+        // DEBUG
+        // System.err.printf("Sixel.consume() %c STATE = %s\n", ch, scanState);
+
+        // Between decimal 63 (inclusive) and 127 (exclusive) --> pixels
+        if ((ch >= 63) && (ch < 127)) {
+            if (scanState == ScanState.COLOR) {
+                setPalette();
+            }
+            if (scanState == ScanState.RASTER) {
+                parseRaster();
+                toGround();
+            }
+            addSixel(ch);
+            toGround();
+            return;
+        }
+
+        if (ch == '#') {
+            // Next color is here, parse what we had before.
+            if (scanState == ScanState.COLOR) {
+                setPalette();
+                toGround();
+            }
+            if (scanState == ScanState.RASTER) {
+                parseRaster();
+                toGround();
+            }
+            scanState = ScanState.COLOR;
+            return;
+        }
+
+        if (ch == '!') {
+            // Repeat count
+            if (scanState == ScanState.COLOR) {
+                setPalette();
+                toGround();
+            }
+            if (scanState == ScanState.RASTER) {
+                parseRaster();
+                toGround();
+            }
+            scanState = ScanState.REPEAT;
+            repeatCount = 0;
+            return;
+        }
+
+        if (ch == '-') {
+            if (scanState == ScanState.COLOR) {
+                setPalette();
+                toGround();
+            }
+            if (scanState == ScanState.RASTER) {
+                parseRaster();
+                toGround();
+            }
+
+            height += 6;
+            x = 0;
+
+            if (height + 6 > image.getHeight()) {
+                // Resize the image, give us another HEIGHT_INCREASE
+                // pixels of vertical length.
+                resizeImage(image.getWidth(),
+                    image.getHeight() + HEIGHT_INCREASE);
+            }
+            return;
+        }
+
+        if (ch == '$') {
+            if (scanState == ScanState.COLOR) {
+                setPalette();
+                toGround();
+            }
+            if (scanState == ScanState.RASTER) {
+                parseRaster();
+                toGround();
+            }
+            x = 0;
+            return;
+        }
+
+        if (ch == '"') {
+            if (scanState == ScanState.COLOR) {
+                setPalette();
+                toGround();
+            }
+            scanState = ScanState.RASTER;
+            return;
+        }
+
+        switch (scanState) {
+
+        case GROUND:
+            // Unknown character.
+            if (DEBUG) {
+                System.err.println("UNKNOWN CHAR: " + ch);
+            }
+            return;
+
+        case RASTER:
+            // 30-39, 3B --> param
+            if ((ch >= '0') && (ch <= '9')) {
+                params[paramsI] *= 10;
+                params[paramsI] += (ch - '0');
+            }
+            if (ch == ';') {
+                if (paramsI < params.length - 1) {
+                    paramsI++;
+                }
+            }
+            return;
+
+        case COLOR:
+            // 30-39, 3B --> param
+            if ((ch >= '0') && (ch <= '9')) {
+                params[paramsI] *= 10;
+                params[paramsI] += (ch - '0');
+            }
+            if (ch == ';') {
+                if (paramsI < params.length - 1) {
+                    paramsI++;
+                }
+            }
+            return;
+
+        case REPEAT:
+            if ((ch >= '0') && (ch <= '9')) {
+                if (repeatCount == -1) {
+                    repeatCount = (ch - '0');
+                } else {
+                    repeatCount *= 10;
+                    repeatCount += (ch - '0');
+                }
+            }
+            return;
+
+        }
+
+    }
+
+}
diff --git a/src/jexer/tterminal/package-info.java b/src/jexer/tterminal/package-info.java
new file mode 100644 (file)
index 0000000..b92d153
--- /dev/null
@@ -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
+ */
+
+/**
+ * An ECMA-48 / ANSI X3.64 style terminal emulator.
+ */
+package jexer.tterminal;
diff --git a/src/jexer/ttree/TDirectoryTreeItem.java b/src/jexer/ttree/TDirectoryTreeItem.java
new file mode 100644 (file)
index 0000000..9bdec01
--- /dev/null
@@ -0,0 +1,211 @@
+/*
+ * 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.ttree;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.LinkedList;
+
+import jexer.TWidget;
+
+/**
+ * TDirectoryTreeItem is a single item in a disk directory tree view.
+ */
+public class TDirectoryTreeItem extends TTreeItem {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * File corresponding to this list item.
+     */
+    private File file;
+
+    /**
+     * The TTreeViewWidget containing this directory tree.
+     */
+    private TTreeViewWidget treeViewWidget;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param view root TTreeViewWidget
+     * @param text text for this item
+     * @param expanded if true, have it expanded immediately
+     * @throws IOException if a java.io operation throws
+     */
+    public TDirectoryTreeItem(final TTreeViewWidget view, final String text,
+        final boolean expanded) throws IOException {
+
+        this(view, text, expanded, true);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param view root TTreeViewWidget
+     * @param text text for this item
+     * @param expanded if true, have it expanded immediately
+     * @param openParents if true, expand all paths up the root path and
+     * return the root path entry
+     * @throws IOException if a java.io operation throws
+     */
+    public TDirectoryTreeItem(final TTreeViewWidget view, final String text,
+        final boolean expanded, final boolean openParents) throws IOException {
+
+        super(view.getTreeView(), text, false);
+
+        this.treeViewWidget = view;
+
+        List<String> parentFiles = new LinkedList<String>();
+        boolean oldExpanded = expanded;
+
+        // Convert to canonical path
+        File rootFile = new File(text);
+        rootFile = rootFile.getCanonicalFile();
+
+        if (openParents) {
+            setExpanded(true);
+
+            // Go up the directory tree
+            File parent = rootFile.getParentFile();
+            while (parent != null) {
+                parentFiles.add(rootFile.getName());
+                rootFile = rootFile.getParentFile();
+                parent = rootFile.getParentFile();
+            }
+        }
+        file = rootFile;
+        if (rootFile.getParentFile() == null) {
+            // This is a filesystem root, use its full name
+            setText(rootFile.getCanonicalPath());
+        } else {
+            // This is a relative path.  We got here because openParents was
+            // false.
+            assert (!openParents);
+            setText(rootFile.getName());
+        }
+        onExpand();
+
+        if (openParents) {
+            TDirectoryTreeItem childFile = this;
+            Collections.reverse(parentFiles);
+            for (String p: parentFiles) {
+                for (TWidget widget: childFile.getChildren()) {
+                    TDirectoryTreeItem child = (TDirectoryTreeItem) widget;
+                    if (child.getText().equals(p)) {
+                        childFile = child;
+                        childFile.setExpanded(true);
+                        childFile.onExpand();
+                        break;
+                    }
+                }
+            }
+            unselect();
+            getTreeView().setSelected(childFile, true);
+            setExpanded(oldExpanded);
+        }
+
+        view.reflowData();
+    }
+
+    // ------------------------------------------------------------------------
+    // TTreeItem --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the File corresponding to this list item.
+     *
+     * @return the File
+     */
+    public final File getFile() {
+        return file;
+    }
+
+    /**
+     * Called when this item is expanded or collapsed.  this.expanded will be
+     * true if this item was just expanded from a mouse click or keypress.
+     */
+    @Override
+    public final void onExpand() {
+        // System.err.printf("onExpand() %s\n", file);
+
+        if (file == null) {
+            return;
+        }
+        getChildren().clear();
+
+        // Make sure we can read it before trying to.
+        if (file.canRead()) {
+            setSelectable(true);
+        } else {
+            setSelectable(false);
+        }
+        assert (file.isDirectory());
+        setExpandable(true);
+
+        if (!isExpanded() || !isExpandable()) {
+            return;
+        }
+
+        File [] listFiles = file.listFiles();
+        if (listFiles != null) {
+            for (File f: listFiles) {
+                // System.err.printf("   -> file %s %s\n", file, file.getName());
+
+                if (f.getName().startsWith(".")) {
+                    // Hide dot-files
+                    continue;
+                }
+                if (!f.isDirectory()) {
+                    continue;
+                }
+
+                try {
+                    TDirectoryTreeItem item = new TDirectoryTreeItem(treeViewWidget,
+                        f.getCanonicalPath(), false, false);
+
+                    item.level = this.level + 1;
+                    getChildren().add(item);
+                } catch (IOException e) {
+                    continue;
+                }
+            }
+        }
+        Collections.sort(getChildren());
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeItem.java b/src/jexer/ttree/TTreeItem.java
new file mode 100644 (file)
index 0000000..44c408b
--- /dev/null
@@ -0,0 +1,483 @@
+/*
+ * 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.ttree;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jexer.TWidget;
+import jexer.bits.CellAttributes;
+import jexer.bits.GraphicsChars;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TTreeItem is a single item in a tree view.
+ */
+public class TTreeItem extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Hang onto reference to my parent TTreeView so I can call its reflow()
+     * when I add a child node.
+     */
+    private TTreeView view;
+
+    /**
+     * Displayable text for this item.
+     */
+    private String text;
+
+    /**
+     * If true, this item is expanded in the tree view.
+     */
+    private boolean expanded = true;
+
+    /**
+     * If true, this item can be expanded in the tree view.
+     */
+    private boolean expandable = false;
+
+    /**
+     * The vertical bars and such along the left side.
+     */
+    private String prefix = "";
+
+    /**
+     * Tree level.
+     */
+    protected int level = 0;
+
+    /**
+     * True means selected.
+     */
+    private boolean selected = false;
+
+    /**
+     * True means select-able.
+     */
+    private boolean selectable = true;
+
+    /**
+     * Whether or not this item is last in its parent's list of children.
+     */
+    private boolean last = false;
+
+    /**
+     * Pointer to the previous keyboard-navigable item (kbUp).  Note package
+     * private access.
+     */
+    TTreeItem keyboardPrevious = null;
+
+    /**
+     * Pointer to the next keyboard-navigable item (kbDown).  Note package
+     * private access.
+     */
+    TTreeItem keyboardNext = null;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param view root TTreeView
+     * @param text text for this item
+     * @param expanded if true, have it expanded immediately
+     */
+    public TTreeItem(final TTreeView view, final String text,
+        final boolean expanded) {
+
+        super(view, 0, 0, view.getWidth() - 3, 1);
+
+        this.text = text;
+        this.expanded = expanded;
+        this.view = view;
+
+        if (view.getTreeRoot() == null) {
+            view.setTreeRoot(this);
+        } else {
+            view.alignTree();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        if ((mouse.getX() == (getExpanderX() - view.getLeftColumn()))
+            && (mouse.getY() == 0)
+        ) {
+            if (level == 0) {
+                // Root node can't switch.
+                return;
+            }
+            if (selectable) {
+                // Flip expanded flag
+                expanded = !expanded;
+                if (expanded == false) {
+                    // Unselect children that became invisible
+                    unselect();
+                }
+                view.setSelected(this, false);
+            }
+            // Let subclasses do something with this
+            onExpand();
+
+            // Update the screen after any thing has expanded/contracted
+            view.alignTree();
+        } else if (mouse.getY() == 0) {
+            // Do the action associated with this item.
+            view.setSelected(this, false);
+            view.dispatch();
+        }
+    }
+
+    /**
+     * Called when this item is expanded or collapsed.  this.expanded will be
+     * true if this item was just expanded from a mouse click or keypress.
+     */
+    public void onExpand() {
+        // Default: do nothing.
+        if (!expandable) {
+            return;
+        }
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbLeft)
+            || keypress.equals(kbRight)
+            || keypress.equals(kbSpace)
+        ) {
+            if (level == 0) {
+                // Root node can't switch.
+                return;
+            }
+            if (selectable) {
+                // Flip expanded flag
+                expanded = !expanded;
+                if (expanded == false) {
+                    // Unselect children that became invisible
+                    unselect();
+                }
+                view.setSelected(this, false);
+            }
+            // Let subclasses do something with this
+            onExpand();
+        } else if (keypress.equals(kbEnter)) {
+            // Do the action associated with this item.
+            view.dispatch();
+        } else {
+            // Pass other keys (tab etc.) on to TWidget's handler.
+            super.onKeypress(keypress);
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Draw this item to a window.
+     */
+    @Override
+    public void draw() {
+        if ((getY() < 0) || (getY() > getParent().getHeight() - 1)) {
+            return;
+        }
+
+        int offset = -view.getLeftColumn();
+
+        CellAttributes color = getTheme().getColor("ttreeview");
+        CellAttributes textColor = getTheme().getColor("ttreeview");
+        CellAttributes expanderColor = getTheme().getColor("ttreeview.expandbutton");
+        CellAttributes selectedColor = getTheme().getColor("ttreeview.selected");
+
+        if (!getParent().isAbsoluteActive()) {
+            color = getTheme().getColor("ttreeview.inactive");
+            textColor = getTheme().getColor("ttreeview.inactive");
+            selectedColor = getTheme().getColor("ttreeview.selected.inactive");
+        }
+
+        if (!selectable) {
+            textColor = getTheme().getColor("ttreeview.unreadable");
+        }
+
+        // Blank out the background
+        hLineXY(0, 0, getWidth(), ' ', color);
+
+        String line = prefix;
+        if (level > 0) {
+            if (last) {
+                line += GraphicsChars.CP437[0xC0];
+            } else {
+                line += GraphicsChars.CP437[0xC3];
+            }
+            line += GraphicsChars.CP437[0xC4];
+            if (expandable) {
+                line += "[ ] ";
+            } else {
+                line += " ";
+            }
+        }
+        putStringXY(offset, 0, line, color);
+        if (selected) {
+            putStringXY(offset + StringUtils.width(line), 0, text, selectedColor);
+        } else {
+            putStringXY(offset + StringUtils.width(line), 0, text, textColor);
+        }
+        if ((level > 0) && (expandable)) {
+            if (expanded) {
+                putCharXY(offset + getExpanderX(), 0, '-', expanderColor);
+            } else {
+                putCharXY(offset + getExpanderX(), 0, '+', expanderColor);
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TTreeItem --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the parent TTreeView.
+     *
+     * @return the parent TTreeView
+     */
+    public final TTreeView getTreeView() {
+        return view;
+    }
+
+    /**
+     * Get the displayable text for this item.
+     *
+     * @return the displayable text for this item
+     */
+    public final String getText() {
+        return text;
+    }
+
+    /**
+     * Set the displayable text for this item.
+     *
+     * @param text the displayable text for this item
+     */
+    public final void setText(final String text) {
+        this.text = text;
+    }
+
+    /**
+     * Get expanded value.
+     *
+     * @return if true, this item is expanded
+     */
+    public final boolean isExpanded() {
+        return expanded;
+    }
+
+    /**
+     * Set expanded value.
+     *
+     * @param expanded new value
+     */
+    public final void setExpanded(final boolean expanded) {
+        if (level == 0) {
+            // Root node can't be unexpanded, ever.
+            this.expanded = true;
+            return;
+        }
+        if (level > 0) {
+            this.expanded = expanded;
+        }
+    }
+
+    /**
+     * Get expandable value.
+     *
+     * @return if true, this item is expandable
+     */
+    public final boolean isExpandable() {
+        return expandable;
+    }
+
+    /**
+     * Set expandable value.
+     *
+     * @param expandable new value
+     */
+    public final void setExpandable(final boolean expandable) {
+        if (level == 0) {
+            // Root node can't be unexpanded, ever.
+            this.expandable = true;
+            return;
+        }
+        if (level > 0) {
+            this.expandable = expandable;
+        }
+    }
+
+    /**
+     * Get the vertical bars and such along the left side.
+     *
+     * @return the vertical bars and such along the left side
+     */
+    public final String getPrefix() {
+        return prefix;
+    }
+
+    /**
+     * Get selected value.
+     *
+     * @return if true, this item is selected
+     */
+    public final boolean isSelected() {
+        return selected;
+    }
+
+    /**
+     * Set selected value.
+     *
+     * @param selected new value
+     */
+    public final void setSelected(final boolean selected) {
+        this.selected = selected;
+    }
+
+    /**
+     * Set selectable value.
+     *
+     * @param selectable new value
+     */
+    public final void setSelectable(final boolean selectable) {
+        this.selectable = selectable;
+    }
+
+    /**
+     * Get the length of the widest item to display.
+     *
+     * @return the maximum number of columns for this item or its children
+     */
+    public int getMaximumColumn() {
+        int max = prefix.length() + 4 + StringUtils.width(text);
+        for (TWidget widget: getChildren()) {
+            TTreeItem item = (TTreeItem) widget;
+            int n = item.prefix.length() + 4 + StringUtils.width(item.text);
+            if (n > max) {
+                max = n;
+            }
+        }
+        return max;
+    }
+
+    /**
+     * Recursively expand the tree into a linear array of items.
+     *
+     * @param prefix vertical bar of parent levels and such that is set on
+     * each child
+     * @param last if true, this is the "last" leaf node of a tree
+     * @return additional items to add to the array
+     */
+    public List<TTreeItem> expandTree(final String prefix, final boolean last) {
+        List<TTreeItem> array = new ArrayList<TTreeItem>();
+        this.last = last;
+        this.prefix = prefix;
+        array.add(this);
+
+        if ((getChildren().size() == 0) || !expanded) {
+            return array;
+        }
+
+        String newPrefix = prefix;
+        if (level > 0) {
+            if (last) {
+                newPrefix += "  ";
+            } else {
+                newPrefix += GraphicsChars.CP437[0xB3];
+                newPrefix += ' ';
+            }
+        }
+        for (int i = 0; i < getChildren().size(); i++) {
+            TTreeItem item = (TTreeItem) getChildren().get(i);
+            if (i == getChildren().size() - 1) {
+                array.addAll(item.expandTree(newPrefix, true));
+            } else {
+                array.addAll(item.expandTree(newPrefix, false));
+            }
+        }
+        return array;
+    }
+
+    /**
+     * Get the x spot for the + or - to expand/collapse.
+     *
+     * @return column of the expand/collapse button
+     */
+    private int getExpanderX() {
+        if ((level == 0) || (!expandable)) {
+            return 0;
+        }
+        return prefix.length() + 3;
+    }
+
+    /**
+     * Recursively unselect me and my children.
+     */
+    public void unselect() {
+        if (selected == true) {
+            selected = false;
+            view.setSelected(null, false);
+        }
+        for (TWidget widget: getChildren()) {
+            if (widget instanceof TTreeItem) {
+                TTreeItem item = (TTreeItem) widget;
+                item.unselect();
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeView.java b/src/jexer/ttree/TTreeView.java
new file mode 100644 (file)
index 0000000..22f72ca
--- /dev/null
@@ -0,0 +1,329 @@
+/*
+ * 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.ttree;
+
+import jexer.TAction;
+import jexer.TKeypress;
+import jexer.TWidget;
+import jexer.event.TKeypressEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TTreeView implements a simple tree view.
+ */
+public class TTreeView extends TWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Root of the tree.
+     */
+    private TTreeItem treeRoot;
+
+    /**
+     * Only one of my children can be selected.
+     */
+    private TTreeItem selectedItem = null;
+
+    /**
+     * The action to perform when the user selects an item.
+     */
+    private TAction action = null;
+
+    /**
+     * The top line currently visible.
+     */
+    private int topLine = 0;
+
+    /**
+     * The left column currently visible.
+     */
+    private int leftColumn = 0;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     */
+    public TTreeView(final TWidget parent, final int x, final int y,
+        final int width, final int height) {
+
+        this(parent, x, y, width, height, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     * @param action action to perform when an item is selected
+     */
+    public TTreeView(final TWidget parent, final int x, final int y,
+        final int width, final int height, final TAction action) {
+
+        super(parent, x, y, width, height);
+        this.action = action;
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbUp)) {
+            // Select the previous item
+            if (selectedItem != null) {
+                if (selectedItem.keyboardPrevious != null) {
+                    setSelected(selectedItem.keyboardPrevious, true);
+                }
+            }
+        } else if (keypress.equals(kbDown)) {
+            // Select the next item
+            if (selectedItem != null) {
+                if (selectedItem.keyboardNext != null) {
+                    setSelected(selectedItem.keyboardNext, true);
+                }
+            }
+        } else if (keypress.equals(kbPgDn)) {
+            for (int i = 0; i < getHeight() - 1; i++) {
+                onKeypress(new TKeypressEvent(TKeypress.kbDown));
+            }
+        } else if (keypress.equals(kbPgUp)) {
+            for (int i = 0; i < getHeight() - 1; i++) {
+                onKeypress(new TKeypressEvent(TKeypress.kbUp));
+            }
+        } else if (keypress.equals(kbHome)) {
+            setSelected((TTreeItem) getChildren().get(0), false);
+            setTopLine(0);
+        } else if (keypress.equals(kbEnd)) {
+            setSelected((TTreeItem) getChildren().get(getChildren().size() - 1),
+                true);
+        } else {
+            if (selectedItem != null) {
+                selectedItem.onKeypress(keypress);
+            } else {
+                // Pass other keys (tab etc.) on to TWidget's handler.
+                super.onKeypress(keypress);
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TWidget ----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+
+    // ------------------------------------------------------------------------
+    // TTreeView --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the root of the tree.
+     *
+     * @return the root of the tree
+     */
+    public final TTreeItem getTreeRoot() {
+        return treeRoot;
+    }
+
+    /**
+     * Set the root of the tree.
+     *
+     * @param treeRoot the new root of the tree
+     */
+    public final void setTreeRoot(final TTreeItem treeRoot) {
+        this.treeRoot = treeRoot;
+        alignTree();
+    }
+
+    /**
+     * Get the tree view item that was selected.
+     *
+     * @return the selected item, or null if no item is selected
+     */
+    public final TTreeItem getSelected() {
+        return selectedItem;
+    }
+
+    /**
+     * Set the new selected tree view item.
+     *
+     * @param item new item that became selected
+     * @param centerWindow if true, move the window to put the selected into
+     * view
+     */
+    public void setSelected(final TTreeItem item, final boolean centerWindow) {
+        if (item != null) {
+            item.setSelected(true);
+        }
+        if ((selectedItem != null) && (selectedItem != item)) {
+            selectedItem.setSelected(false);
+        }
+        selectedItem = item;
+
+        if (centerWindow) {
+            int y = 0;
+            for (TWidget widget: getChildren()) {
+                if (widget == selectedItem) {
+                    break;
+                }
+                y++;
+            }
+            topLine = y - (getHeight() - 1)/2;
+            if (topLine > getChildren().size() - getHeight()) {
+                topLine = getChildren().size() - getHeight();
+            }
+            if (topLine < 0) {
+                topLine = 0;
+            }
+        }
+
+        if (selectedItem != null) {
+            activate(selectedItem);
+        }
+    }
+
+    /**
+     * Perform user selection action.
+     */
+    public void dispatch() {
+        if (action != null) {
+            action.DO(this);
+        }
+    }
+
+    /**
+     * Get the left column value.  0 is the leftmost column.
+     *
+     * @return the left column
+     */
+    public int getLeftColumn() {
+        return leftColumn;
+    }
+
+    /**
+     * Set the left column value.  0 is the leftmost column.
+     *
+     * @param leftColumn the new left column
+     */
+    public void setLeftColumn(final int leftColumn) {
+        this.leftColumn = leftColumn;
+    }
+
+    /**
+     * Get the top line (row) value.  0 is the topmost line.
+     *
+     * @return the top line
+     */
+    public int getTopLine() {
+        return topLine;
+    }
+
+    /**
+     * Set the top line value.  0 is the topmost line.
+     *
+     * @param topLine the new top line
+     */
+    public void setTopLine(final int topLine) {
+        this.topLine = topLine;
+    }
+
+    /**
+     * Get the total line (rows) count, based on the items that are visible
+     * and expanded.
+     *
+     * @return the line count
+     */
+    public int getTotalLineCount() {
+        if (treeRoot == null) {
+            return 0;
+        }
+        return getChildren().size();
+    }
+
+    /**
+     * Get the length of the widest item to display.
+     *
+     * @return the maximum number of columns for this item or its children
+     */
+    public int getMaximumColumn() {
+        if (treeRoot == null) {
+            return 0;
+        }
+        return treeRoot.getMaximumColumn();
+    }
+
+    /**
+     * Update the Y positions of all the children items to match the current
+     * topLine value.  Note package private access.
+     */
+    void alignTree() {
+        if (treeRoot == null) {
+            return;
+        }
+
+        // As we walk the list we also adjust next/previous pointers,
+        // resulting in a doubly-linked list but only of the expanded items.
+        TTreeItem p = null;
+
+        for (int i = 0; i < getChildren().size(); i++) {
+            TTreeItem item = (TTreeItem) getChildren().get(i);
+
+            if (p != null) {
+                item.keyboardPrevious = p;
+                p.keyboardNext = item;
+            }
+            p = item;
+
+            item.setY(i - topLine);
+            item.setWidth(getWidth());
+        }
+
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeViewWidget.java b/src/jexer/ttree/TTreeViewWidget.java
new file mode 100644 (file)
index 0000000..13beac3
--- /dev/null
@@ -0,0 +1,450 @@
+/*
+ * 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.ttree;
+
+import jexer.TAction;
+import jexer.THScroller;
+import jexer.TKeypress;
+import jexer.TScrollableWidget;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TTreeViewWidget wraps a tree view with horizontal and vertical scrollbars.
+ */
+public class TTreeViewWidget extends TScrollableWidget {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The TTreeView
+     */
+    private TTreeView treeView;
+
+    /**
+     * If true, move the window to put the selected item in view.  This
+     * normally only happens once after setting treeRoot.
+     */
+    private boolean centerWindow = false;
+
+    /**
+     * Maximum width of a single line.
+     */
+    private int maxLineWidth;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     */
+    public TTreeViewWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height) {
+
+        this(parent, x, y, width, height, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent parent widget
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     * @param action action to perform when an item is selected
+     */
+    public TTreeViewWidget(final TWidget parent, final int x, final int y,
+        final int width, final int height, final TAction action) {
+
+        super(parent, x, y, width, height);
+
+        treeView = new TTreeView(this, 0, 0, getWidth() - 1, getHeight() - 1,
+            action);
+
+        vScroller = new TVScroller(this, getWidth() - 1, 0, getHeight() - 1);
+        hScroller = new THScroller(this, 0, getHeight() - 1, getWidth() - 1);
+
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param event resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent event) {
+        super.onResize(event);
+
+        if (event.getType() == TResizeEvent.Type.WIDGET) {
+            treeView.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                    getWidth() - 1, getHeight() - 1));
+            return;
+        } else {
+            super.onResize(event);
+        }
+    }
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouse.isMouseWheelUp()) {
+            verticalDecrement();
+        } else if (mouse.isMouseWheelDown()) {
+            verticalIncrement();
+        } else {
+            // Pass to the TreeView or scrollbars
+            super.onMouseDown(mouse);
+        }
+
+        // Update the view to reflect the new scrollbar positions
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        // Pass to the TreeView or scrollbars
+        super.onMouseUp(mouse);
+
+        // Update the view to reflect the new scrollbar positions
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    /**
+     * Handle mouse motion events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        // Pass to the TreeView or scrollbars
+        super.onMouseMotion(mouse);
+
+        // Update the view to reflect the new scrollbar positions
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (keypress.equals(kbShiftLeft)
+            || keypress.equals(kbCtrlLeft)
+            || keypress.equals(kbAltLeft)
+        ) {
+            horizontalDecrement();
+        } else if (keypress.equals(kbShiftRight)
+            || keypress.equals(kbCtrlRight)
+            || keypress.equals(kbAltRight)
+        ) {
+            horizontalIncrement();
+        } else if (keypress.equals(kbShiftUp)
+            || keypress.equals(kbCtrlUp)
+            || keypress.equals(kbAltUp)
+        ) {
+            verticalDecrement();
+        } else if (keypress.equals(kbShiftDown)
+            || keypress.equals(kbCtrlDown)
+            || keypress.equals(kbAltDown)
+        ) {
+            verticalIncrement();
+        } else if (keypress.equals(kbShiftPgUp)
+            || keypress.equals(kbCtrlPgUp)
+            || keypress.equals(kbAltPgUp)
+        ) {
+            bigVerticalDecrement();
+        } else if (keypress.equals(kbShiftPgDn)
+            || keypress.equals(kbCtrlPgDn)
+            || keypress.equals(kbAltPgDn)
+        ) {
+            bigVerticalIncrement();
+        } else if (keypress.equals(kbPgDn)) {
+            for (int i = 0; i < getHeight() - 2; i++) {
+                treeView.onKeypress(new TKeypressEvent(TKeypress.kbDown));
+            }
+            reflowData();
+            return;
+        } else if (keypress.equals(kbPgUp)) {
+            for (int i = 0; i < getHeight() - 2; i++) {
+                treeView.onKeypress(new TKeypressEvent(TKeypress.kbUp));
+            }
+            reflowData();
+            return;
+        } else if (keypress.equals(kbHome)) {
+            treeView.setSelected((TTreeItem) treeView.getChildren().get(0),
+                false);
+            treeView.setTopLine(0);
+            reflowData();
+            return;
+        } else if (keypress.equals(kbEnd)) {
+            treeView.setSelected((TTreeItem)  treeView.getChildren().get(
+                treeView.getChildren().size() - 1), true);
+            reflowData();
+            return;
+        } else if (keypress.equals(kbTab)) {
+            getParent().switchWidget(true);
+            return;
+        } else if (keypress.equals(kbShiftTab)
+                || keypress.equals(kbBackTab)) {
+            getParent().switchWidget(false);
+            return;
+        } else {
+            treeView.onKeypress(keypress);
+
+            // Update the scrollbars to reflect the new data position
+            reflowData();
+            return;
+        }
+
+        // Update the view to reflect the new scrollbar position
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    // ------------------------------------------------------------------------
+    // 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);
+        }
+        if (treeView != null) {
+            treeView.setWidth(getWidth() - 1);
+        }
+        reflowData();
+    }
+
+    /**
+     * Override TWidget's height: we need to set child widget heights.
+     *
+     * @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(getHeight() - 1);
+        }
+        if (treeView != null) {
+            treeView.setHeight(getHeight() - 1);
+        }
+        reflowData();
+    }
+
+    /**
+     * Resize text and scrollbars for a new width/height.
+     */
+    @Override
+    public void reflowData() {
+        if (treeView == null) {
+            return;
+        }
+
+        int selectedRow = 0;
+        boolean foundSelectedRow = false;
+
+        // Reset the keyboard list, expandTree() will recreate it.
+        for (TWidget widget: treeView.getChildren()) {
+            TTreeItem item = (TTreeItem) widget;
+            item.keyboardPrevious = null;
+            item.keyboardNext = null;
+        }
+
+        // Expand the tree into a linear list
+        treeView.getChildren().clear();
+        treeView.getChildren().addAll(treeView.getTreeRoot().expandTree("",
+                true));
+
+        // Locate the selected row and maximum line width
+        for (TWidget widget: treeView.getChildren()) {
+            TTreeItem item = (TTreeItem) widget;
+
+            if (item == treeView.getSelected()) {
+                foundSelectedRow = true;
+            }
+            if (!foundSelectedRow) {
+                selectedRow++;
+            }
+
+            int lineWidth = StringUtils.width(item.getText())
+                + item.getPrefix().length() + 4;
+            if (lineWidth > maxLineWidth) {
+                maxLineWidth = lineWidth;
+            }
+        }
+
+        if ((centerWindow) && (foundSelectedRow)) {
+            if ((selectedRow < getVerticalValue())
+                || (selectedRow > getVerticalValue() + getHeight() - 2)
+            ) {
+                treeView.setTopLine(selectedRow);
+                centerWindow = false;
+            }
+        }
+        treeView.alignTree();
+
+        // Rescale the scroll bars
+        setVerticalValue(treeView.getTopLine());
+        setBottomValue(treeView.getTotalLineCount() - (getHeight() - 1));
+        if (getBottomValue() < getTopValue()) {
+            setBottomValue(getTopValue());
+        }
+        if (getVerticalValue() > getBottomValue()) {
+            setVerticalValue(getBottomValue());
+        }
+        setRightValue(maxLineWidth - 2);
+        if (getHorizontalValue() > getRightValue()) {
+            setHorizontalValue(getRightValue());
+        }
+
+    }
+
+    // ------------------------------------------------------------------------
+    // TTreeView --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the underlying TTreeView.
+     *
+     * @return the TTreeView
+     */
+    public TTreeView getTreeView() {
+        return treeView;
+    }
+
+    /**
+     * Get the root of the tree.
+     *
+     * @return the root of the tree
+     */
+    public final TTreeItem getTreeRoot() {
+        return treeView.getTreeRoot();
+    }
+
+    /**
+     * Set the root of the tree.
+     *
+     * @param treeRoot the new root of the tree
+     */
+    public final void setTreeRoot(final TTreeItem treeRoot) {
+        treeView.setTreeRoot(treeRoot);
+    }
+
+    /**
+     * Set treeRoot.
+     *
+     * @param treeRoot ultimate root of tree
+     * @param centerWindow if true, move the window to put the root in view
+     */
+    public void setTreeRoot(final TTreeItem treeRoot,
+        final boolean centerWindow) {
+
+        treeView.setTreeRoot(treeRoot);
+        this.centerWindow = centerWindow;
+    }
+
+    /**
+     * Get the tree view item that was selected.
+     *
+     * @return the selected item, or null if no item is selected
+     */
+    public final TTreeItem getSelected() {
+        return treeView.getSelected();
+    }
+
+    /**
+     * Set the new selected tree view item.
+     *
+     * @param item new item that became selected
+     * @param centerWindow if true, move the window to put the selected into
+     * view
+     */
+    public void setSelected(final TTreeItem item, final boolean centerWindow) {
+        treeView.setSelected(item, centerWindow);
+    }
+
+    /**
+     * Perform user selection action.
+     */
+    public void dispatch() {
+        treeView.dispatch();
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeViewWindow.java b/src/jexer/ttree/TTreeViewWindow.java
new file mode 100644 (file)
index 0000000..f418383
--- /dev/null
@@ -0,0 +1,408 @@
+/*
+ * 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.ttree;
+
+import jexer.TAction;
+import jexer.TApplication;
+import jexer.THScroller;
+import jexer.TScrollableWindow;
+import jexer.TVScroller;
+import jexer.TWidget;
+import jexer.bits.StringUtils;
+import jexer.event.TKeypressEvent;
+import jexer.event.TMouseEvent;
+import jexer.event.TResizeEvent;
+import static jexer.TKeypress.*;
+
+/**
+ * TTreeViewWindow wraps a tree view with horizontal and vertical scrollbars
+ * in a standalone window.
+ */
+public class TTreeViewWindow extends TScrollableWindow {
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * The TTreeView
+     */
+    private TTreeView treeView;
+
+    /**
+     * If true, move the window to put the selected item in view.  This
+     * normally only happens once after setting treeRoot.
+     */
+    private boolean centerWindow = false;
+
+    /**
+     * Maximum width of a single line.
+     */
+    private int maxLineWidth;
+
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     * @param title the window title
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+     * @param height height of tree view
+     */
+    public TTreeViewWindow(final TApplication parent, final String title,
+        final int x, final int y, final int width, final int height,
+        final int flags) {
+
+        this(parent, title, x, y, width, height, flags, null);
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param parent the main application
+     * @param title the window title
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param width width of tree view
+     * @param height height of tree view
+     * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+     * @param action action to perform when an item is selected
+     */
+    public TTreeViewWindow(final TApplication parent, final String title,
+        final int x, final int y, final int width, final int height,
+        final int flags, final TAction action) {
+
+        super(parent, title, x, y, width, height, flags);
+
+        treeView = new TTreeView(this, 0, 0, getWidth() - 2, getHeight() - 2,
+            action);
+
+        hScroller = new THScroller(this, 17, getHeight() - 2, getWidth() - 20);
+        vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+
+        /*
+        System.err.println("TTreeViewWindow()");
+        for (TWidget w: getChildren()) {
+            System.err.println("    " + w + " " + w.isActive());
+        }
+        */
+    }
+
+    // ------------------------------------------------------------------------
+    // Event handlers ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle mouse press events.
+     *
+     * @param mouse mouse button press event
+     */
+    @Override
+    public void onMouseDown(final TMouseEvent mouse) {
+        if (mouse.isMouseWheelUp()) {
+            verticalDecrement();
+        } else if (mouse.isMouseWheelDown()) {
+            verticalIncrement();
+        } else {
+            // Pass to the TreeView or scrollbars
+            super.onMouseDown(mouse);
+        }
+
+        // Update the view to reflect the new scrollbar positions
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    /**
+     * Handle mouse release events.
+     *
+     * @param mouse mouse button release event
+     */
+    @Override
+    public void onMouseUp(final TMouseEvent mouse) {
+        // Pass to the TreeView or scrollbars
+        super.onMouseUp(mouse);
+
+        // Update the view to reflect the new scrollbar positions
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    /**
+     * Handle mouse motion events.
+     *
+     * @param mouse mouse motion event
+     */
+    @Override
+    public void onMouseMotion(final TMouseEvent mouse) {
+        // Pass to the TreeView or scrollbars
+        super.onMouseMotion(mouse);
+
+        // Update the view to reflect the new scrollbar positions
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    /**
+     * Handle keystrokes.
+     *
+     * @param keypress keystroke event
+     */
+    @Override
+    public void onKeypress(final TKeypressEvent keypress) {
+        if (inKeyboardResize) {
+            // Let TWindow do its job.
+            super.onKeypress(keypress);
+            return;
+        }
+
+        // Give the shortcut bar a shot at this.
+        if (statusBar != null) {
+            if (statusBar.statusBarKeypress(keypress)) {
+                return;
+            }
+        }
+
+        if (keypress.equals(kbShiftLeft)
+            || keypress.equals(kbCtrlLeft)
+            || keypress.equals(kbAltLeft)
+        ) {
+            horizontalDecrement();
+        } else if (keypress.equals(kbShiftRight)
+            || keypress.equals(kbCtrlRight)
+            || keypress.equals(kbAltRight)
+        ) {
+            horizontalIncrement();
+        } else if (keypress.equals(kbShiftUp)
+            || keypress.equals(kbCtrlUp)
+            || keypress.equals(kbAltUp)
+        ) {
+            verticalDecrement();
+        } else if (keypress.equals(kbShiftDown)
+            || keypress.equals(kbCtrlDown)
+            || keypress.equals(kbAltDown)
+        ) {
+            verticalIncrement();
+        } else if (keypress.equals(kbShiftPgUp)
+            || keypress.equals(kbCtrlPgUp)
+            || keypress.equals(kbAltPgUp)
+        ) {
+            bigVerticalDecrement();
+        } else if (keypress.equals(kbShiftPgDn)
+            || keypress.equals(kbCtrlPgDn)
+            || keypress.equals(kbAltPgDn)
+        ) {
+            bigVerticalIncrement();
+        } else {
+            treeView.onKeypress(keypress);
+
+            // Update the scrollbars to reflect the new data position
+            reflowData();
+            return;
+        }
+
+        // Update the view to reflect the new scrollbar position
+        treeView.setTopLine(getVerticalValue());
+        treeView.setLeftColumn(getHorizontalValue());
+        reflowData();
+    }
+
+    // ------------------------------------------------------------------------
+    // TScrollableWindow ------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Handle window/screen resize events.
+     *
+     * @param resize resize event
+     */
+    @Override
+    public void onResize(final TResizeEvent resize) {
+        if (resize.getType() == TResizeEvent.Type.WIDGET) {
+            // Resize the treeView field.
+            TResizeEvent treeSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                resize.getWidth() - 2, resize.getHeight() - 2);
+            treeView.onResize(treeSize);
+
+            // Have TScrollableWindow handle the scrollbars.
+            super.onResize(resize);
+
+            // Now re-center the treeView field.
+            if (treeView.getSelected() != null) {
+                treeView.setSelected(treeView.getSelected(), true);
+            }
+            reflowData();
+            return;
+        }
+    }
+
+    /**
+     * Resize text and scrollbars for a new width/height.
+     */
+    @Override
+    public void reflowData() {
+        int selectedRow = 0;
+        boolean foundSelectedRow = false;
+
+        // Reset the keyboard list, expandTree() will recreate it.
+        for (TWidget widget: treeView.getChildren()) {
+            TTreeItem item = (TTreeItem) widget;
+            item.keyboardPrevious = null;
+            item.keyboardNext = null;
+        }
+
+        // Expand the tree into a linear list
+        treeView.getChildren().clear();
+        treeView.getChildren().addAll(treeView.getTreeRoot().expandTree("",
+                true));
+
+        // Locate the selected row and maximum line width
+        for (TWidget widget: treeView.getChildren()) {
+            TTreeItem item = (TTreeItem) widget;
+
+            if (item == treeView.getSelected()) {
+                foundSelectedRow = true;
+            }
+            if (!foundSelectedRow) {
+                selectedRow++;
+            }
+
+            int lineWidth = StringUtils.width(item.getText())
+                + item.getPrefix().length() + 4;
+            if (lineWidth > maxLineWidth) {
+                maxLineWidth = lineWidth;
+            }
+        }
+
+        if ((centerWindow) && (foundSelectedRow)) {
+            if ((selectedRow < getVerticalValue())
+                || (selectedRow > getVerticalValue() + getHeight() - 3)
+            ) {
+                treeView.setTopLine(selectedRow);
+                centerWindow = false;
+            }
+        }
+        treeView.alignTree();
+
+        // Rescale the scroll bars
+        setVerticalValue(treeView.getTopLine());
+        setBottomValue(treeView.getTotalLineCount() - (getHeight() - 2));
+        if (getBottomValue() < getTopValue()) {
+            setBottomValue(getTopValue());
+        }
+        if (getVerticalValue() > getBottomValue()) {
+            setVerticalValue(getBottomValue());
+        }
+        setRightValue(maxLineWidth - 4);
+        if (getHorizontalValue() > getRightValue()) {
+            setHorizontalValue(getRightValue());
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // TTreeView --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the underlying TTreeView.
+     *
+     * @return the TTreeView
+     */
+    public TTreeView getTreeView() {
+        return treeView;
+    }
+
+    /**
+     * Get the root of the tree.
+     *
+     * @return the root of the tree
+     */
+    public final TTreeItem getTreeRoot() {
+        return treeView.getTreeRoot();
+    }
+
+    /**
+     * Set the root of the tree.
+     *
+     * @param treeRoot the new root of the tree
+     */
+    public final void setTreeRoot(final TTreeItem treeRoot) {
+        treeView.setTreeRoot(treeRoot);
+    }
+
+    /**
+     * Set treeRoot.
+     *
+     * @param treeRoot ultimate root of tree
+     * @param centerWindow if true, move the window to put the root in view
+     */
+    public void setTreeRoot(final TTreeItem treeRoot,
+        final boolean centerWindow) {
+
+        treeView.setTreeRoot(treeRoot);
+        this.centerWindow = centerWindow;
+    }
+
+    /**
+     * Get the tree view item that was selected.
+     *
+     * @return the selected item, or null if no item is selected
+     */
+    public final TTreeItem getSelected() {
+        return treeView.getSelected();
+    }
+
+    /**
+     * Set the new selected tree view item.
+     *
+     * @param item new item that became selected
+     * @param centerWindow if true, move the window to put the selected into
+     * view
+     */
+    public void setSelected(final TTreeItem item, final boolean centerWindow) {
+        treeView.setSelected(item, centerWindow);
+    }
+
+    /**
+     * Perform user selection action.
+     */
+    public void dispatch() {
+        treeView.dispatch();
+    }
+
+}
diff --git a/src/jexer/ttree/package-info.java b/src/jexer/ttree/package-info.java
new file mode 100644 (file)
index 0000000..1e1fdfd
--- /dev/null
@@ -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
+ */
+
+/**
+ * TTreeView and supporting classes.
+ */
+package jexer.ttree;
diff --git a/test/expected_test.story/cbz.cbz b/test/expected_test.story/cbz.cbz
new file mode 100644 (file)
index 0000000..28cc25b
Binary files /dev/null and b/test/expected_test.story/cbz.cbz differ
diff --git a/test/expected_test.story/epub.epub b/test/expected_test.story/epub.epub
new file mode 100644 (file)
index 0000000..776d05d
Binary files /dev/null and b/test/expected_test.story/epub.epub differ
diff --git a/test/expected_test.story/html/html.info b/test/expected_test.story/html/html.info
new file mode 100644 (file)
index 0000000..506e477
--- /dev/null
@@ -0,0 +1,18 @@
+TITLE="The trials of Fanfan"
+AUTHOR="UnknownArtist366"
+DATE="2018"
+SUBJECT="test"
+SOURCE="text"
+URL="file:/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+TAGS=""
+UUID="/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+LUID=""
+LANG="en"
+IMAGES_DOCUMENT="false"
+TYPE="html"
+COVER=""
+EPUBCREATOR="Fanfix (by Niki)"
+PUBLISHER=""
+WORDCOUNT="57"
+CREATION_DATE="2018-03-28 08:40:18"
+FAKE_COVER="false"
diff --git a/test/expected_test.story/html/html.txt b/test/expected_test.story/html/html.txt
new file mode 100644 (file)
index 0000000..a3a3af3
--- /dev/null
@@ -0,0 +1,27 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+——————————————————————
+
+This ‘story’ is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+—————————————————
+
+“Yes, quotes!”, I said.
+‘Those can start with a single- or double quote sign’, I continued.
+“They also supports other characters, and an optionnal leading dash.”
+“The optionnal leading dash is enough to signify “quote”.”
+
+Chapter 2: “Quote” test
+———————————————————————
+
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
diff --git a/test/expected_test.story/html/index.html b/test/expected_test.story/html/index.html
new file mode 100644 (file)
index 0000000..aade9c3
--- /dev/null
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html>
+<head>
+       <meta http-equiv='content-type' content='text/html; charset=utf-8'>
+       <meta name='viewport' content='width=device-width, initial-scale=1.0'>
+       <link rel='stylesheet' type='text/css' href='style.css'>
+       <title>The trials of Fanfan</title>
+</head>
+<body>
+
+       <div class="titlepage">
+               <h1>The trials of Fanfan</h1>
+                       <div class="type"></div>
+               <div class="cover">
+                       <img src="cover.png"></img>
+               </div>
+               <div class="author">UnknownArtist366</div>
+       </div>
+       <hr/><br/>              <div class='normals'>
+               <span class='normal'>This ‘story’ is nothing more than a test file to check</span>
+               <span class='normal'>that the program can convert it into different</span>
+               <span class='normal'>formats correctly.</span>
+               </div>
+
+       <br/>
+       <h2>
+               <span class='chap'>Chapter <span class='chapnumber'>1</span>:</span> 
+               <span class='chaptitle'>Quotes</span>
+       </h2>
+       
+       <div class='chapter_content'>
+               <div class='dialogues'>
+                       <div class='dialogue'>&mdash; “Yes, quotes!”, I said.</div>
+                       <div class='dialogue'>&mdash; ‘Those can start with a single- or double quote sign’, I continued.</div>
+                       <div class='dialogue'>&mdash; “They also supports other characters, and an optionnal leading dash.”</div>
+                       <div class='dialogue'>&mdash; “The optionnal leading dash is enough to signify “quote”.”</div>
+               </div>
+
+       </div>
+       <h2>
+               <span class='chap'>Chapter <span class='chapnumber'>2</span>:</span> 
+               <span class='chaptitle'>“Quote” test</span>
+       </h2>
+       
+       <div class='chapter_content'>
+               <div class='normals'>
+               <span class='normal'>This test was just for the chapter title.</span>
+               <span class='normal'>We can also check for breaks.</span>
+               </div>
+               <hr class='break'/>
+               <div class='normals'>
+               <span class='normal'>This was a break space.</span>
+               </div>
+
+       </div></body>
diff --git a/test/expected_test.story/html/style.css b/test/expected_test.story/html/style.css
new file mode 100644 (file)
index 0000000..6b6d0d2
--- /dev/null
@@ -0,0 +1,112 @@
+html {
+       text-align: justify;
+       max-width: 800px;
+       margin: auto;
+}
+
+.titlepage {
+       padding-left: 10%;
+       padding-right: 10%;
+       width: 80%;
+}
+
+h1 {
+       padding-bottom: 0;
+       margin-bottom: 0;
+       text-align: left;
+}
+
+.type {
+       position: relative;
+       font-size: large;
+       color: #666666;
+       font-weight: bold;
+       padding-bottom: 10px;
+       text-align: left;
+}
+
+.cover, .page-image {
+       width: 100%;
+}
+
+.cover img {
+       height: 45%;
+       max-width: 100%;
+       margin: auto;
+}
+
+.author {
+       text-align: right;
+       font-size: large;
+       font-style: italic;
+}
+
+.book, .chapter_content {
+       NO_text-indent: 40px;
+       padding-top: 40px;
+       padding-left: 5%;
+       padding-right: 5%;
+       width: 90%;
+}
+
+h2 {
+       border: 1px solid black;
+       color: #222222;
+       padding-left: 10px;
+       padding-right: 10px;
+       display: block;
+       padding-bottom: 0;
+       margin-bottom: 0;
+}
+
+h2 .chap {
+       color: #000000;
+       font-size: large;
+       font-variant: small-caps;
+       display: block;
+}
+
+h2 .chap:first-letter {
+       font-weight: bold;
+}
+
+h2 .chapnumber {
+       color: #000000;
+       font-size: xx-large;
+}
+
+h2 .chaptitle {
+       color: #444444;
+       font-size: large;
+       font-style: italic;
+       padding-bottom: 5px;
+       text-align: right;
+       display: block;
+}
+
+.normals {
+}
+
+.normal {
+       /* Can be removed if you want a more "compact" view */
+       display: block;
+}
+
+.blank {
+       /* Can be removed if you want a more "compact" view */
+       height: 24px;
+       width: 100%;
+}
+
+hr.break {
+       /* Can be removed if you want a more "compact" view */
+       margin-top: 48px;
+       margin-bottom: 48px;
+}
+
+.dialogues {
+}
+
+.dialogue {
+       font-style: italic;
+}
diff --git a/test/expected_test.story/info_text b/test/expected_test.story/info_text
new file mode 100644 (file)
index 0000000..768b3cc
--- /dev/null
@@ -0,0 +1,24 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+
+This 'story' is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+
+"Yes, quotes!", I said.
+'Those can start with a single- or double quote sign', I continued.
+"They also supports other characters, and an optionnal leading dash."
+"The optionnal leading dash is enough to signify "quote"."
+
+Chapter 2: “Quote” test
+
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
diff --git a/test/expected_test.story/info_text.info b/test/expected_test.story/info_text.info
new file mode 100644 (file)
index 0000000..cc522dd
--- /dev/null
@@ -0,0 +1,18 @@
+TITLE="The trials of Fanfan"
+AUTHOR="UnknownArtist366"
+DATE="2018"
+SUBJECT="test"
+SOURCE="text"
+URL="file:/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+TAGS=""
+UUID="/media/xubuntu/sd32/workspace/fanfix/test/test.story"
+LUID=""
+LANG="en"
+IMAGES_DOCUMENT="false"
+TYPE="info_text"
+COVER=""
+EPUBCREATOR="Fanfix (by Niki)"
+PUBLISHER=""
+WORDCOUNT="57"
+CREATION_DATE="2018-03-28 08:39:39"
+FAKE_COVER="false"
diff --git a/test/expected_test.story/latex.tex b/test/expected_test.story/latex.tex
new file mode 100644 (file)
index 0000000..d4dd68f
--- /dev/null
@@ -0,0 +1,44 @@
+%
+% This LaTeX document was auto-generated by Fanfic Reader, created by Niki.
+%
+
+\documentclass[a4paper]{book}
+\usepackage[english]{babel}
+\usepackage[utf8]{inputenc}
+\usepackage[T1]{fontenc}
+\usepackage{lmodern}
+\newcommand{\br}{\vspace{10 mm}}
+\newcommand{\say}{--- \noindent\emph}
+\hyphenpenalty=1000
+\tolerance=5000
+\begin{document}
+\date{2018}
+\title{The trials of Fanfan}
+\author{UnknownArtist366}
+\maketitle
+
+
+
+\chapter{Quotes}
+
+\say{``Yes, quotes!'', I said.}
+
+\say{`Those can start with a single- or double quote sign', I continued.}
+
+\noindent{}
+\say{``They also supports other characters, and an optionnal leading dash.''}
+
+\noindent{}
+\say{``The optionnal leading dash is enough to signify ``quote''.''}
+
+\noindent{}
+
+
+\chapter{``Quote'' test}
+This test was just for the chapter title.
+We can also check for breaks.
+
+\br
+This was a break space.
+
+\end{document}
diff --git a/test/expected_test.story/text.txt b/test/expected_test.story/text.txt
new file mode 100644 (file)
index 0000000..a3a3af3
--- /dev/null
@@ -0,0 +1,27 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+——————————————————————
+
+This ‘story’ is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+—————————————————
+
+“Yes, quotes!”, I said.
+‘Those can start with a single- or double quote sign’, I continued.
+“They also supports other characters, and an optionnal leading dash.”
+“The optionnal leading dash is enough to signify “quote”.”
+
+Chapter 2: “Quote” test
+———————————————————————
+
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
diff --git a/test/test.story b/test/test.story
new file mode 100644 (file)
index 0000000..7365b60
--- /dev/null
@@ -0,0 +1,23 @@
+The trials of Fanfan
+by UnknownArtist366 (2018)
+
+Chapter 0: Description
+
+This 'story' is nothing more than a test file to check
+that the program can convert it into different
+formats correctly.
+
+Chapter 1: Quotes
+- "Yes, quotes!", I said.
+'Those can start with a single- or double quote sign', I continued.
+- «They also supports other characters, and an optionnal leading dash.»
+- The optionnal leading dash is enough to signify "quote".
+
+Chapter 2: "Quote" test
+This test was just for the chapter title.
+We can also check for breaks.
+
+* * *
+
+This was a break space.
+