changeset 31:0c6c18a733b7

Compiles, new menu still a mess.
author David Barts <n5jrn@me.com>
date Thu, 30 Jan 2020 16:01:51 -0800
parents 0e88c6bed11e
children 4d87bedb3f65
files src/name/blackcap/clipman/Coerce.kt src/name/blackcap/clipman/CoerceDialog.kt src/name/blackcap/clipman/Main.kt src/name/blackcap/clipman/Menus.kt src/name/blackcap/clipman/Misc.kt src/name/blackcap/clipman/Pasteboard.kt src/name/blackcap/clipman/PasteboardQueue.kt src/name/blackcap/clipman/PasteboardView.kt src/name/blackcap/clipman/RtfToHtml.kt src/name/blackcap/clipman/SearchDialog.kt
diffstat 10 files changed, 426 insertions(+), 47 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/Coerce.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -0,0 +1,137 @@
+/*
+ * Font coercion. Would be easier if we didn't have to rummage through
+ * a doc tree and could use SAX-style callbacks instead. Would run faster
+ * if we didn't have a doc tree. Would use less memory if we didn't have
+ * a doc tree. But all HTML parsers these days force a doc tree on you.
+ * Sigh.
+ */
+package name.blackcap.clipman
+
+import org.jsoup.Jsoup
+import org.jsoup.nodes.*
+import java.util.Formatter
+import java.util.logging.Level
+import java.util.logging.Logger
+
+/**
+ * Coerce fonts in HTML document.
+ * @param uncoerced String HTML document in
+ * @param pFamily String proportionally-spaced font family
+ * @param pSize Float proportionally-spaced font size
+ * @param mFamily String monospaced font family
+ * @param mSize Float monospaced font size
+ * @return HTML document with coerced fonts
+ */
+fun coerceHTML(uncoerced: String, pFamily: String, pSize: Float, mFamily: String, mSize: Float): String {
+    val doc = Jsoup.parse(uncoerced)
+
+    /* apply standard scrubbing */
+    val head = _scrub(doc)
+
+    /* remove all stylesheets (and their content) and references to same */
+    doc.select("link[rel=stylesheet],style").remove()
+
+    /* add a style sheet of our own */
+    head?.appendElement("style")?.appendChild(DataNode(Formatter().run {
+        format("%nbody { font-family: \"%s\"; font-size: %.2f; }%n",
+            pFamily, pSize)
+        format("code, kbd, pre, samp, tt { font-family: \"%s\"; font-size: %.2f; }%n",
+            mFamily, mSize)
+        toString()
+    }))
+
+    /* remove all styling tags, but keep their content */
+    doc.select("basefont,big,div,font,small,span").forEach { discardTag ->
+        discardTag.getChildrenAsArray().forEach {
+            discardTag.before(it)
+        }
+        discardTag.remove()
+    }
+
+    /* remove all styling attributes */
+    val hitList = arrayOf("bgcolor", "class", "color", "style")
+    val selector = hitList.joinToString(prefix = "[", separator = "],[", postfix = "]")
+    doc.select(selector).forEach {
+        it.removeAll(*hitList)
+    }
+
+    /* remove body styling attributes */
+    doc.selectFirst(":root>body")?.removeAll("background", "text", "link", "alink", "vlink")
+
+    /* clean up horizontal rules */
+    doc.select("hr").forEach {
+        it.removeAll("shade", "noshade")
+    }
+
+    /* that's all! */
+    return _output(doc)
+}
+
+/**
+ * "Scrub" an HTML document (make it Java and clipboard-friendly)
+ * @param unscrubbed String HTML document in
+ * @return scrubbed HTML document
+ */
+fun scrub(unscrubbed: String): String {
+    val doc = Jsoup.parse(unscrubbed)
+    _scrub(doc)
+    return _output(doc)
+}
+
+private fun _scrub(doc: Document): Element? {
+    /* remove any doctype or XML declarations */
+    doc.getChildrenAsArray().forEach {
+        if (it is DocumentType || it is XmlDeclaration) {
+            it.remove()
+        }
+    }
+
+    /* remove all non-HTML-4.01 attributes from <html> */
+    doc.attributes().map { it.key } .forEach {
+        if (it !in setOf("lang", "dir")) {
+            doc.removeAttr(it)
+        }
+    }
+
+    /* remove any conflicting charset declarations */
+    doc.select("meta[http-equiv=content-type],meta[charset]").remove()
+
+    /* add a charset declaration of our own */
+    val head = doc.selectFirst(":root>head")
+    if (head == null) {
+        LOGGER.log(Level.SEVERE, "no head found!")
+        return null
+    }
+    head.prependElement("meta").run {
+        attr("http-equiv", "Content-Type")
+        attr("content", "text/html; charset=" + CHARSET_NAME)
+    }
+    return head
+}
+
+private fun _output(doc: Document): String {
+    doc.outputSettings()
+        .charset(CHARSET_NAME)
+        .syntax(Document.OutputSettings.Syntax.xml);
+    return doc.outerHtml()
+}
+
+/**
+ * Turns node list to array. Needed to avoid ConcurrentModificationException.
+ * Ah, the joys of the doc tree.
+ * @return Array<Node>
+ */
+fun Node.getChildrenAsArray(): Array<Node> = childNodes().run {
+    Array<Node>(size) { get(it) }
+}
+
+/**
+ * Remove all specified attributes from the element.
+ */
+fun Element.removeAll(vararg hitList: String) {
+    hitList.forEach {
+        if (hasAttr(it)) {
+            removeAttr(it)
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/CoerceDialog.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -0,0 +1,221 @@
+/*
+ * The dialog that controls font corecion.
+ */
+package name.blackcap.clipman
+
+import java.awt.Color
+import java.awt.Font
+import java.awt.GraphicsEnvironment
+import java.awt.Toolkit
+import java.awt.event.ActionEvent
+import java.awt.event.ActionListener
+import java.util.logging.Level
+import java.util.logging.Logger
+import javax.swing.*
+import javax.swing.event.DocumentEvent
+import javax.swing.event.DocumentListener
+
+class CoerceDialog: JDialog(frame.v), ActionListener {
+    private val FONTS =
+        GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames.copyOf().apply {
+            sort()
+        }
+    private val SIZES =
+        arrayOf(9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 16.0f, 18.0f,
+            24.0f, 36.0f, 48.0f, 64.0f, 72.0f, 96.0f, 144.0f, 288.0f)
+    private val DSIZEI = 6  /* SIZES[6] = 16 */
+
+    /* the proportional font family */
+    private val _pFamily = JComboBox<String>(FONTS).apply {
+        selectedIndex = getFontIndex(Font.SERIF)
+    }
+    val pFamily: String
+    get() {
+        return _pFamily.selectedItem as String
+    }
+
+    /* the proportional font size */
+    private val _pSize = JComboBox<Float>(SIZES).also {
+        it.selectedIndex = DSIZEI
+        it.setEditable(true)
+        it.actionCommand = "Size"
+        it.addActionListener(this)
+    }
+    val pSize: Float
+    get() {
+        return _pSize.selectedItem as Float
+    }
+
+    /* the monospaced font family */
+    private val _mFamily = JComboBox<String>(FONTS).apply {
+        selectedIndex = getFontIndex(Font.MONOSPACED)
+    }
+    val mFamily: String
+    get() {
+        return _mFamily.selectedItem as String
+    }
+
+    /* the monospaced font size */
+    private val _mSize = JComboBox<Float>(SIZES).also {
+        it.selectedIndex = DSIZEI
+        it.setEditable(true)
+        it.actionCommand = "Size"
+        it.addActionListener(this)
+    }
+    val mSize: Float
+    get() {
+        return _mSize.selectedItem as Float
+    }
+
+    /* standard spacing between elements (10 pixels ≅ 1/7") and half that */
+    private val BW = 5
+    private val BW2 = 10
+    private val VSPACE = Box.createVerticalStrut(BW)
+    private val VSPACE2 = Box.createVerticalStrut(BW2)
+    private val HSPACE2 = Box.createHorizontalStrut(BW2)
+
+    /* buttons */
+    private val _coerce = JButton("Coerce").also {
+        it.actionCommand = "Coerce"
+        it.addActionListener(this)
+    }
+
+    private val _cancel = JButton("Cancel").also {
+        it.actionCommand = "Cancel"
+        it.addActionListener(this)
+    }
+
+    /* initializer */
+    init {
+        title = "Coerce Fonts"
+        contentPane.apply {
+            add(Box(BoxLayout.Y_AXIS).apply {
+                add(VSPACE2)
+                add(leftLabel("Coerce proportionally-spaced text to:"))
+                add(VSPACE)
+                add(Box(BoxLayout.X_AXIS).apply {
+                    add(HSPACE2)
+                    add(Box.createGlue())
+                    add(Box(BoxLayout.Y_AXIS).apply {
+                        add(leftLabel("Family:"))
+                        add(_pFamily)
+                    })
+                    add(Box.createGlue())
+                    add(Box(BoxLayout.Y_AXIS).apply {
+                        add(leftLabel("Size:"))
+                        add(_pSize)
+                    })
+                    add(Box.createGlue())
+                    add(HSPACE2)
+                })
+                add(VSPACE2)
+                add(JSeparator())
+                add(VSPACE2)
+                add(leftLabel("Coerce monospaced text to:"))
+                add(VSPACE)
+                add(Box(BoxLayout.X_AXIS).apply {
+                    add(HSPACE2)
+                    add(Box.createGlue())
+                    add(Box(BoxLayout.Y_AXIS).apply {
+                        add(leftLabel("Family:"))
+                        add(_mFamily)
+                    })
+                    add(Box.createGlue())
+                    add(Box(BoxLayout.Y_AXIS).apply {
+                        add(leftLabel("Size:"))
+                        add(_mSize)
+                    })
+                    add(Box.createGlue())
+                    add(HSPACE2)
+                })
+                add(VSPACE2)
+                add(JSeparator())
+                add(VSPACE2)
+                add(Box(BoxLayout.X_AXIS).apply {
+                    add(Box.createGlue())
+                    add(_cancel)
+                    add(Box.createGlue())
+                    add(_coerce)
+                    add(Box.createGlue())
+                })
+                add(VSPACE2)
+            })
+        }
+        rootPane.setDefaultButton(_coerce)
+        pack()
+    }
+
+    private fun leftLabel(text: String) = JLabel(text).apply {
+        horizontalAlignment = JLabel.LEFT
+        alignmentX = JLabel.LEFT_ALIGNMENT
+    }
+
+    override fun actionPerformed(e: ActionEvent) {
+        when (e.actionCommand) {
+            "Size" -> {
+                val source = e.source as? JComboBox<Float>
+                if (source != null && (source.selectedItem as Float) < 1.0f) {
+                    Toolkit.getDefaultToolkit().beep()
+                    source.selectedIndex = DSIZEI
+                }
+            }
+            "Coerce" -> {
+                setVisible(false)
+                coerce()
+            }
+            "Cancel" -> setVisible(false)
+        }
+    }
+
+    private fun coerce() {
+        val selected = queue.v.getSelected()
+        if (selected == null) {
+            JOptionPane.showMessageDialog(frame.v,
+                "No item selected.",
+                "Error",
+                JOptionPane.ERROR_MESSAGE)
+        } else {
+            val (plain, html) = when (selected.contents) {
+                is PasteboardItem.Plain ->
+                    Pair(selected.contents.plain, null)
+                is PasteboardItem.HTML ->
+                    Pair(selected.contents.plain, selected.contents.html)
+                is PasteboardItem.RTF ->
+                    Pair(selected.contents.plain, selected.contents.html)
+            }
+            if (html == null) {
+                JOptionPane.showMessageDialog(frame.v,
+                    "Only styled texts may be coerced.",
+                    "Error",
+                    JOptionPane.ERROR_MESSAGE)
+            } else {
+                PasteboardItem.write(
+                    PasteboardItem.HTML(
+                        plain,
+                        coerceHTML(html, normalizeFont(pFamily), pSize,
+                            normalizeFont(mFamily), mSize)))
+            }
+        }
+    }
+
+    private fun getFontIndex(font: String): Int {
+        val found = FONTS.indexOf(font)
+        if (found < 0) {
+            LOGGER.log(Level.WARNING, "font '${font}' not found")
+            return 0
+        }
+        return found
+    }
+
+    private fun normalizeFont(font: String): String {
+        val lcFont = font.toLowerCase()
+        return when (lcFont) {
+            in setOf("monospace", "serif", "sans-serif") -> lcFont
+            "monospaced" -> "monospace"
+            "sansserif" -> "sans-serif"
+            else -> font
+        }
+    }
+}
+
+val coerceDialog = CoerceDialog()
--- a/src/name/blackcap/clipman/Main.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/Main.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -72,7 +72,7 @@
                         is PasteboardItem.RTF -> Pair(contents.plain, contents.html)
                     }
                     val piv = if (html == null) {
-                        PasteboardItemView("Plain text", ClipText().apply {
+                        PasteboardItemView("Plain text", ClipText(contents).apply {
                             contentType = "text/plain"
                             text = plain
                             font = Font(Font.MONOSPACED, Font.PLAIN, MONO_SIZE)
@@ -84,7 +84,7 @@
                             style.addStyleSheet(defaultStyleSheet)
                             styleSheet = style
                         }
-                        PasteboardItemView("Styled text", ClipText().apply {
+                        PasteboardItemView("Styled text", ClipText(contents).apply {
                             editorKit = hek
                             text = dhtml
                             resize()
@@ -134,7 +134,12 @@
         queue.v.deselectAll()
         source.selected = true
         source.validate()
-        SelectionRequired.enable()
+        anyRequired.enable()
+        source.basedOn.let {
+            if (it is PasteboardItem.HTML || it is PasteboardItem.RTF) {
+                styledRequired.enable()
+            }
+        }
     }
 
  	override fun mousePressed(e: MouseEvent) {
--- a/src/name/blackcap/clipman/Menus.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/Menus.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -18,7 +18,7 @@
         when (e.actionCommand) {
             "File.Quit" -> System.exit(0)
             "Edit.Clone" -> onlyIfSelected { PasteboardItem.write(it.contents) }
-            "Edit.Coerce" -> onlyIfSelected { doCoerceFonts(it) }
+            "Edit.Coerce" -> onlyIfSelected { coerceDialog.setVisible(true) }
             "Edit.Find" -> searchDialog.setVisible(true)
             "Edit.FindAgain" -> searchDialog.find()
             else -> throw RuntimeException("unexpected actionCommand!")
@@ -31,20 +31,57 @@
             JOptionPane.showMessageDialog(frame.v,
                 "No item selected.",
                 "Error",
-                JOptionPane.ERROR_MESSAGE);
-         } else {
+                JOptionPane.ERROR_MESSAGE)
+        } else {
             block(selected)
         }
     }
 
-    private fun doCoerceFonts(item: QueueItem) {
-        /* not finished */
+    private fun clone(contents: PasteboardItem) {
+        val (plain, html) = when(contents) {
+            is PasteboardItem.Plain -> Pair(contents.plain, null)
+            is PasteboardItem.HTML -> Pair(contents.plain, contents.html)
+            is PasteboardItem.RTF -> Pair(contents.plain, contents.html)
+        }
+        if (html == null) {
+            PasteboardItem.write(PasteboardItem.Plain(plain))
+        } else {
+            PasteboardItem.write(PasteboardItem.HTML(plain, scrub(html)))
+        }
     }
 }
 
 val menuItemListener = MenuItemListener()
 
 /**
+ * Track menu items that require something to be selected in order
+ * to work, and allow them to be enabled and disabled en masse.
+ */
+class SelectionRequired {
+    private val cache = LinkedList<JMenuItem>()
+
+    fun add(item: JMenuItem): JMenuItem {
+        cache.add(item)
+        return item
+    }
+
+    fun enable() {
+        cache.forEach {
+            it.setEnabled(true)
+        }
+    }
+
+    fun disable() {
+        cache.forEach {
+            it.setEnabled(false)
+        }
+    }
+}
+
+val anyRequired = SelectionRequired()
+val styledRequired = SelectionRequired()
+
+/**
  * Our menu bar. What we display depends somewhat on the system type, as
  * the Mac gives us a gratuitous menu bar entry for handling some stuff.
  */
@@ -60,13 +97,13 @@
             })
         }
         add(JMenu("Edit").apply {
-            add(SelectionRequired.add(JMenuItem("Clone").apply {
+            add(anyRequired.add(JMenuItem("Clone").apply {
                 setEnabled(false)
                 actionCommand = "Edit.Clone"
                 addActionListener(menuItemListener)
                 makeShortcut(KeyEvent.VK_C)
             }))
-            add(SelectionRequired.add(JMenuItem("Coerce…").apply {
+            add(styledRequired.add(JMenuItem("Coerce…").apply {
                 setEnabled(false)
                 actionCommand = "Edit.Coerce"
                 addActionListener(menuItemListener)
@@ -112,11 +149,11 @@
  */
 class MyPopupMenu: JPopupMenu() {
     init {
-        add(SelectionRequired.add(JMenuItem("Clone").apply {
+        add(anyRequired.add(JMenuItem("Clone").apply {
             actionCommand = "Edit.Clone"
             addActionListener(menuItemListener)
         }))
-        add(SelectionRequired.add(JMenuItem("Coerce…").apply {
+        add(styledRequired.add(JMenuItem("Coerce…").apply {
             actionCommand = "Edit.Coerce"
             addActionListener(menuItemListener)
         }))
@@ -124,28 +161,3 @@
 }
 
 val popupMenu = MyPopupMenu()
-
-/**
- * Track menu items that require something to be selected in order
- * to work, and allow them to be enabled and disabled en masse.
- */
-object SelectionRequired {
-    private val cache = LinkedList<JMenuItem>()
-
-    fun add(item: JMenuItem): JMenuItem {
-        cache.add(item)
-        return item
-    }
-
-    fun enable() {
-        cache.forEach {
-            it.setEnabled(true)
-        }
-    }
-
-    fun disable() {
-        cache.forEach {
-            it.setEnabled(false)
-        }
-    }
-}
\ No newline at end of file
--- a/src/name/blackcap/clipman/Misc.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/Misc.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -9,6 +9,12 @@
 import javax.swing.text.JTextComponent
 
 /**
+ * Name of the character set (encoding) we preferentially use for many
+ * things.
+ */
+val CHARSET_NAME = "UTF-8"
+
+/**
  * Allows a val to have lateinit functionality. It is an error to attempt
  * to retrieve this object's value unless it has been set, and it is an
  * error to attempt to set the value of an already-set object.
--- a/src/name/blackcap/clipman/Pasteboard.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/Pasteboard.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -18,9 +18,6 @@
 import java.util.logging.Logger
 import kotlin.collections.HashMap
 
-/* Constants, etc. */
-val CHARSET_NAME = "UTF-8"
-
 /**
  * Represents an item of data in the clipboard and how to read and
  * write it.
--- a/src/name/blackcap/clipman/PasteboardQueue.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/PasteboardQueue.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -110,6 +110,7 @@
             pos = search(needle, text, pos)
             if (pos >= 0) {
                 si.highlighter.addHighlight(pos, pos+needle.length, painter)
+                si.scrollRectToVisible(si.getBounds(null))
                 break
             }
             norigin = Offset(norigin.inQueue + incr, start)
@@ -150,7 +151,8 @@
                 var extra = queue.removeFirst().view
                 inSwingThread {
                     if (extra.searchable.selected) {
-                        SelectionRequired.disable()
+                        anyRequired.disable()
+                        styledRequired.disable()
                     }
                     parent.remove(extra.contents)
                 }
--- a/src/name/blackcap/clipman/PasteboardView.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/PasteboardView.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -18,7 +18,7 @@
 /**
  * What we use to display the text that is or was in the clipboard.
  */
-class ClipText: JTextPane() {
+class ClipText(val basedOn: PasteboardItem): JTextPane() {
     private val normalBorder = BorderFactory.createCompoundBorder(
             BorderFactory.createLineBorder(Color.GRAY, INNER_BORDER),
             BorderFactory.createEmptyBorder(MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER))
--- a/src/name/blackcap/clipman/RtfToHtml.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/RtfToHtml.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -13,7 +13,7 @@
 import java.io.OutputStream
 import java.io.UnsupportedEncodingException
 
-private val RTF_CHARSET_NAME = "UTF-8"
+private val RTF_CHARSET_NAME = CHARSET_NAME
 private val LANG = "en_US." + RTF_CHARSET_NAME
 private val UNRTF = System.getenv("UNRTF")
 
--- a/src/name/blackcap/clipman/SearchDialog.kt	Wed Jan 29 21:56:12 2020 -0800
+++ b/src/name/blackcap/clipman/SearchDialog.kt	Thu Jan 30 16:01:51 2020 -0800
@@ -8,7 +8,6 @@
 import java.awt.event.ActionEvent
 import java.awt.event.ActionListener
 import javax.swing.*
-import javax.swing.border.*
 import javax.swing.event.DocumentEvent
 import javax.swing.event.DocumentListener
 
@@ -58,9 +57,9 @@
         throw RuntimeException("impossible button state!")
     }
 
-    /* standard spacing between elements (18 pixels = 1/4") and half that */
-    private val BW = 9
-    private val BW2 = 2 * BW
+    /* standard spacing between elements (10 pixels ≅ 1/7") and half that */
+    private val BW = 5
+    private val BW2 = 10
 
     /* where to begin searching from. unlike the other properties, this
        one is read/write. null means to start from the beginning on