changeset 27:8aa2dfac27eb

Big reorg; compiled but untested.
author David Barts <n5jrn@me.com>
date Wed, 29 Jan 2020 10:50:07 -0800
parents ff35fabaea3a
children f1fcc1281dad
files .hgignore build.xml 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, 660 insertions(+), 200 deletions(-) [+]
line wrap: on
line diff
--- a/.hgignore	Wed Jan 29 10:47:46 2020 -0800
+++ b/.hgignore	Wed Jan 29 10:50:07 2020 -0800
@@ -1,3 +1,4 @@
 ~$
 \.bak$
 ^work/
+^bundles/
--- a/build.xml	Wed Jan 29 10:47:46 2020 -0800
+++ b/build.xml	Wed Jan 29 10:50:07 2020 -0800
@@ -109,7 +109,8 @@
         <fx:application mainClass="${app.entry}" name="${app.name}" toolkit="swing"
           version="1.0"/>
         <fx:info description="ClipMan, a clipboard manager." title="${app.name}"
-          vendor="David Barts &lt;n5jrn@me.com&gt;"/>
+          vendor="David Barts &lt;n5jrn@me.com&gt;"
+          copyright="© MMXX, David W. Barts"/>
         <fx:resources>
           <fx:fileset dir="${basedir}" includes="${lc.app.name}.jar"/>
         </fx:resources>
--- a/src/name/blackcap/clipman/Main.kt	Wed Jan 29 10:47:46 2020 -0800
+++ b/src/name/blackcap/clipman/Main.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -4,25 +4,19 @@
 package name.blackcap.clipman
 
 import java.awt.BorderLayout
-import java.awt.Color
 import java.awt.Container
 import java.awt.Dimension
 import java.awt.Font
-import java.awt.Toolkit;
 import java.awt.datatransfer.*
-import java.awt.event.ActionEvent
-import java.awt.event.ActionListener
-import java.awt.event.KeyEvent
+import java.awt.event.MouseEvent
+import java.awt.event.MouseListener
 import java.awt.event.WindowEvent
 import java.awt.event.WindowListener
 import java.util.Date
-import java.util.concurrent.Semaphore
 import java.util.logging.Level
 import java.util.logging.Logger
 import javax.swing.*
 import javax.swing.border.*
-import javax.swing.text.JTextComponent
-import javax.swing.text.html.HTMLEditorKit
 import javax.swing.text.html.StyleSheet
 import kotlin.concurrent.thread
 import org.jsoup.Jsoup
@@ -35,19 +29,19 @@
 val CPWIDTH = 640
 val CPHEIGHT = 480
 
-/* border widths */
+/* width of main panel border */
 val PANEL_BORDER = 9
-val OUTER_BORDER_TOP = 3
-val OUTER_BORDER = 9
-val INNER_BORDER = 1
-val MARGIN_BORDER = 3
 
 /* default font sizes in the text-display panes */
 val MONO_SIZE = 14
 val PROP_SIZE = 16
 
+/* the queue of data we deal with and the main application frame */
+val queue = LateInit<PasteboardQueue>()
+val frame = LateInit<JFrame>()
+
 /* kills the updating thread (and does a system exit) when needed */
-class KillIt(val thr: Thread) : WindowListener {
+class KillIt() : WindowListener {
     // events we don't care about
     override fun windowActivated(e: WindowEvent) {}
     override fun windowClosed(e: WindowEvent) {}
@@ -58,41 +52,14 @@
 
     // and the one we do
     override fun windowClosing(e: WindowEvent) {
-        thr.run { interrupt(); join() }
         LOGGER.log(Level.INFO, "execution complete")
         System.exit(0)
     }
 }
 
-class ClipText: JTextPane() {
-    override fun getMaximumSize(): Dimension {
-        return Dimension(Int.MAX_VALUE, preferredSize.height)
-    }
-}
-
-/* HTMLEditorKit shares all stylesheets. How unbelievably braindamaged. */
-class MyEditorKit: HTMLEditorKit() {
-    private var _styleSheet = defaultStyleSheet
-    override fun getStyleSheet() = _styleSheet
-    override fun setStyleSheet(value: StyleSheet) {
-        _styleSheet = value
-    }
-
-    val defaultStyleSheet: StyleSheet
-    get() {
-        return super.getStyleSheet()
-    }
-}
-
 /* the updating thread */
-class UpdateIt(val queue: PasteboardQueue, val interval: Int): Thread() {
+class UpdateIt(val interval: Int): Thread(), MouseListener {
     @Volatile var enabled = true
-    private val outerBorder =
-        MatteBorder(OUTER_BORDER_TOP, OUTER_BORDER, OUTER_BORDER, OUTER_BORDER,
-            queue.parent.background)
-    private val stdBorder =
-        CompoundBorder(LineBorder(Color.GRAY, INNER_BORDER),
-            EmptyBorder(MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER))
 
     override fun run() {
         var oldContents: PasteboardItem? = null
@@ -100,52 +67,30 @@
             if (enabled) {
                 var contents = PasteboardItem.read()
                 if ((contents != null) && (contents != oldContents)) {
-                    val stdWidth = queue.parent.size.width - 2 * (PANEL_BORDER+OUTER_BORDER+INNER_BORDER+MARGIN_BORDER)
-                    val widget = JPanel().apply {
-                        layout = BoxLayout(this, BoxLayout.Y_AXIS)
-                        background = queue.parent.background
-                        border = outerBorder
-                    }
                     val (plain, html) = when(contents) {
                         is PasteboardItem.Plain -> Pair(contents.plain, null)
                         is PasteboardItem.HTML -> Pair(null, contents.html)
                         is PasteboardItem.RTF -> Pair(contents.plain, contents.html)
                     }
-                    var searchable: JTextComponent? = null
-                    if (html == null) {
-                        widget.run {
-                            add(stdLabel("Plain text"))
-                            searchable = ClipText().apply {
-                                contentType = "text/plain"
-                                text = plain
-                                font = Font(Font.MONOSPACED, Font.PLAIN, MONO_SIZE)
-                                border = stdBorder
-                                autoSize(stdWidth)
-                                setEditable(false)
-                                alignmentX = JTextPane.LEFT_ALIGNMENT
-                            }
-                            add(searchable)
-                        }
+                    val piv = if (html == null) {
+                        PasteboardItemView("Plain text", ClipText().apply {
+                            contentType = "text/plain"
+                            text = plain
+                            font = Font(Font.MONOSPACED, Font.PLAIN, MONO_SIZE)
+                        })
                     } else {
-                        widget.run {
-                            add(stdLabel("Styled text"))
-                            val (dhtml, style) = preproc(html)
-                            val hek = MyEditorKit().apply {
-                                style.addStyleSheet(defaultStyleSheet)
-                                styleSheet = style
-                            }
-                            searchable = ClipText().apply {
-                                editorKit = hek
-                                text = dhtml
-                                border = stdBorder
-                                autoSize(stdWidth)
-                                setEditable(false)
-                                alignmentX = JTextPane.LEFT_ALIGNMENT
-                            }
-                            add(searchable)
+                        val (dhtml, style) = preproc(html)
+                        val hek = MyEditorKit().apply {
+                            style.addStyleSheet(defaultStyleSheet)
+                            styleSheet = style
                         }
+                        PasteboardItemView("Styled text", ClipText().apply {
+                            editorKit = hek
+                            text = dhtml
+                        })
                     }
-                    queue.add(QueueItem(widget, searchable!!, contents))
+                    piv.searchable.addMouseListener(this)
+                    queue.v.add(QueueItem(contents, piv))
                     oldContents = contents
                 }
             }
@@ -160,15 +105,10 @@
         }
     }
 
-    private fun stdLabel(text: String) = JLabel(text).apply {
-        horizontalAlignment = JLabel.LEFT
-        alignmentX = JLabel.LEFT_ALIGNMENT
-    }
-
     private fun preproc(html: String): Pair<String, StyleSheet> {
         val sty = StyleSheet().apply {
-            addRule("body { font-family: serif; font-size: %d; }".format(PROP_SIZE))
-            addRule("code, kbd, pre, samp, tt { font-family: monospace; font-size: %d; }".format(MONO_SIZE))
+            addRule("body { font-family: serif; font-size: ${PROP_SIZE}; }")
+            addRule("code, kbd, pre, samp, tt { font-family: monospace; font-size: ${MONO_SIZE}; }")
         }
         val scrubbed = Jsoup.parse(html).run {
             select("style").forEach {
@@ -182,113 +122,67 @@
         }
         return Pair(scrubbed, sty)
     }
+
+    /* MouseListener methods */
+
+    override fun mouseClicked(e: MouseEvent) {
+        val source = e.getSource() as? ClipText
+        if (source == null) {
+            return
+        }
+        queue.v.deselectAll()
+        source.selected = true
+        source.validate()
+        SelectionRequired.enable()
+    }
+
+ 	override fun mousePressed(e: MouseEvent) {
+ 	    maybeShowPopup(e)
+ 	}
+
+ 	override fun mouseReleased(e: MouseEvent) {
+ 	    maybeShowPopup(e)
+ 	}
+
+ 	private fun maybeShowPopup(e: MouseEvent) {
+ 	    if (e.isPopupTrigger()) {
+ 	        popupMenu.show(e.component, e.x, e.y)
+ 	    }
+ 	}
+
+ 	override fun mouseEntered(e: MouseEvent) { }
+ 	override fun mouseExited(e: MouseEvent) { }
 }
 
+/* entry point */
 fun main(args: Array<String>) {
     LOGGER.log(Level.INFO, "beginning execution")
     if (OS.type == OS.MAC) {
         System.setProperty("apple.laf.useScreenMenuBar", "true")
     }
-    var frame: JFrame? = null
-    var con: JPanel? = null
+    lateinit var con: JPanel
     inSynSwingThread {
         UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
-        frame = JFrame(MYNAME)
+        frame.v = JFrame(MYNAME)
         con = JPanel().apply {
             layout = BoxLayout(this, BoxLayout.Y_AXIS)
             border = EmptyBorder(PANEL_BORDER, PANEL_BORDER, PANEL_BORDER, PANEL_BORDER)
-            background = frame!!.background
+            background = frame.v.background
         }
-        frame!!.apply {
-            jMenuBar = makeMenuBar()
+        frame.v.jMenuBar = menuBar
+        frame.v.apply {
             contentPane.add(
-                JScrollPane(con!!).apply {
+                JScrollPane(con).apply {
                     verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS
                     horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
                     preferredSize = Dimension(CPWIDTH, CPHEIGHT)
-                    background = frame!!.background
+                    background = frame.v.background
                 }, BorderLayout.CENTER)
             pack()
             setVisible(true)
+            addWindowListener(KillIt())
         }
     }
-    val queue = PasteboardQueue(con!!, 10)
-    val updater = UpdateIt(queue, 1000).apply { start() }
-    inSwingThread { frame!!.addWindowListener(KillIt(updater)) }
-}
-
-class MenuItemListener: ActionListener {
-    override fun actionPerformed(e: ActionEvent) {
-        println(e.actionCommand + " selected")
-    }
+    queue.v = PasteboardQueue(con, 10)
+    UpdateIt(1000).apply { start() }
 }
-
-fun makeMenuBar() = JMenuBar().apply {
-    val al: ActionListener = MenuItemListener()
-    if (OS.type != OS.MAC) {
-        add(JMenu("File").apply {
-            add(JMenuItem("Quit").apply {
-                actionCommand = "File.Quit"
-                addActionListener(al)
-                makeShortcut(KeyEvent.VK_Q)
-            })
-        })
-    }
-    add(JMenu("Edit").apply {
-        add(JMenuItem("Clone").apply {
-            actionCommand = "Edit.Clone"
-            addActionListener(al)
-            makeShortcut(KeyEvent.VK_C)
-        })
-        add(JMenuItem("Coerce…").apply {
-            actionCommand = "Edit.Coerce"
-            addActionListener(al)
-            makeShortcut(KeyEvent.VK_K)
-        })
-        add(JMenuItem("Delete").apply {
-            actionCommand = "Edit.Delete"
-            addActionListener(al)
-            setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0))
-        })
-        add(JMenuItem("Find…").apply {
-            actionCommand = "Edit.Find"
-            addActionListener(al)
-            makeShortcut(KeyEvent.VK_F)
-        })
-    })
-    if (OS.type != OS.MAC) {
-        add(JMenu("Help").apply {
-            add(JMenuItem("About ClipMan…").apply {
-                actionCommand = "Help.About"
-                addActionListener(al)
-            })
-        })
-    }
-}
-
-fun inSwingThread(block: () -> Unit) {
-    SwingUtilities.invokeLater(Runnable(block))
-}
-
-fun inSynSwingThread(block: () -> Unit) {
-    val ready = Semaphore(0)
-    inSwingThread {
-        block()
-        ready.release()
-    }
-    ready.acquire()
-}
-
-fun JTextComponent.autoSize(width: Int): Unit {
-    val SLOP = 10
-    val dim = Dimension(width, width)
-    preferredSize = dim
-    size = dim
-    val r = modelToView(document.length)
-    preferredSize = Dimension(width, r.y + r.height + SLOP)
-}
-
-val SC_KEY_MASK = Toolkit.getDefaultToolkit().menuShortcutKeyMask
-fun JMenuItem.makeShortcut(key: Int): Unit {
-    setAccelerator(KeyStroke.getKeyStroke(key, SC_KEY_MASK))
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/Menus.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -0,0 +1,166 @@
+/*
+ * Menu-related stuff, pertaining to both the menu bar and popup menus.
+ */
+package name.blackcap.clipman
+
+import java.awt.Toolkit
+import java.awt.event.ActionEvent
+import java.awt.event.ActionListener
+import java.awt.event.KeyEvent
+import java.util.LinkedList
+import javax.swing.*
+
+/**
+ * Listen to actionEvents from both menu bar and popup menu selections.
+ */
+class MenuItemListener: ActionListener {
+    override fun actionPerformed(e: ActionEvent) {
+        when (e.actionCommand) {
+            "File.Quit" -> System.exit(0)
+            "Edit.Clone" -> onlyIfSelected { queue.v.add(it) }
+            "Edit.Coerce" -> onlyIfSelected { doCoerceFonts(it) }
+            "Edit.Delete" -> onlyIfSelected {
+                queue.v.delete(it)
+                SelectionRequired.disable()
+            }
+            "Edit.Find" -> searchDialog.setVisible(true)
+            "Edit.FindAgain" -> searchDialog.find()
+            else -> throw RuntimeException("unexpected actionCommand!")
+        }
+    }
+
+    private fun onlyIfSelected(block: (QueueItem) -> Unit) {
+        val selected = queue.v.getSelected()
+        if (selected == null) {
+            JOptionPane.showMessageDialog(frame.v,
+                "No item selected.",
+                "Error",
+                JOptionPane.ERROR_MESSAGE);
+         } else {
+            block(selected)
+        }
+    }
+
+    private fun doCoerceFonts(item: QueueItem) {
+        /* not finished */
+    }
+}
+
+val menuItemListener = MenuItemListener()
+
+/**
+ * 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.
+ */
+class MyMenuBar: JMenuBar() {
+    init {
+        if (OS.type != OS.MAC) {
+            add(JMenu("File").apply {
+                add(JMenuItem("Quit").apply {
+                    actionCommand = "File.Quit"
+                    addActionListener(menuItemListener)
+                    makeShortcut(KeyEvent.VK_Q)
+                })
+            })
+        }
+        add(JMenu("Edit").apply {
+            add(SelectionRequired.add(JMenuItem("Clone").apply {
+                setEnabled(false)
+                actionCommand = "Edit.Clone"
+                addActionListener(menuItemListener)
+                makeShortcut(KeyEvent.VK_C)
+            }))
+            add(SelectionRequired.add(JMenuItem("Coerce…").apply {
+                setEnabled(false)
+                actionCommand = "Edit.Coerce"
+                addActionListener(menuItemListener)
+                makeShortcut(KeyEvent.VK_K)
+            }))
+            add(SelectionRequired.add(JMenuItem("Delete").apply {
+                setEnabled(false)
+                actionCommand = "Edit.Delete"
+                addActionListener(menuItemListener)
+                setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0))
+            }))
+            add(JMenuItem("Find…").apply {
+                actionCommand = "Edit.Find"
+                addActionListener(menuItemListener)
+                makeShortcut(KeyEvent.VK_F)
+            })
+            add(JMenuItem("Find Again").apply {
+                setEnabled(false)
+                actionCommand = "Edit.FindAgain"
+                addActionListener(menuItemListener)
+                makeShortcut(KeyEvent.VK_G)
+            })
+        })
+        if (OS.type != OS.MAC) {
+            add(JMenu("Help").apply {
+                add(JMenuItem("About ClipMan…").apply {
+                    actionCommand = "Help.About"
+                    addActionListener(menuItemListener)
+                })
+            })
+        }
+    }
+
+    fun getMenu(name: String): JMenu? {
+        subElements.forEach {
+            val jmenu = it.component as? JMenu
+            if (jmenu?.text == name) {
+                return jmenu
+            }
+        }
+        return null
+    }
+}
+
+val menuBar = MyMenuBar()
+
+/**
+ * The popup menu we display when the user requests so atop a clipboard
+ * item.
+ */
+class MyPopupMenu: JPopupMenu() {
+    init {
+        add(SelectionRequired.add(JMenuItem("Clone").apply {
+            actionCommand = "Edit.Clone"
+            addActionListener(menuItemListener)
+        }))
+        add(SelectionRequired.add(JMenuItem("Coerce…").apply {
+            actionCommand = "Edit.Coerce"
+            addActionListener(menuItemListener)
+        }))
+        add(SelectionRequired.add(JMenuItem("Delete").apply {
+            actionCommand = "Edit.Delete"
+            addActionListener(menuItemListener)
+        }))
+    }
+}
+
+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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/Misc.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -0,0 +1,94 @@
+/*
+ * Miscellaneous utility stuff.
+ */
+package name.blackcap.clipman
+
+import java.awt.Dimension
+import java.awt.Toolkit
+import javax.swing.*
+import javax.swing.text.JTextComponent
+
+/**
+ * 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.
+ * @param &lt;T&gt; type of the associated value
+ */
+class LateInit<T> {
+    private var _v: T? = null
+
+    /**
+     * The value associated with this object. The name of this property is
+     * deliberately short.
+     */
+    var v: T
+    get() {
+        if (_v == null) {
+            throw IllegalStateException("cannot retrieve un-initialized value")
+        } else {
+            return _v!!
+        }
+    }
+    @Synchronized set(value) {
+        if (_v != null) {
+            throw IllegalStateException("cannot set already-initialized value")
+        }
+        _v = value
+    }
+}
+
+/**
+ * Run something in the Swing thread, asynchronously.
+ * @param block lambda containing code to run
+ */
+fun inSwingThread(block: () -> Unit) {
+    SwingUtilities.invokeLater(Runnable(block))
+}
+
+/**
+ * Run something in the Swing thread, synchronously.
+ * @param block lambda containing code to run
+ */
+fun inSynSwingThread(block: () -> Unit) {
+    SwingUtilities.invokeAndWait(Runnable(block))
+}
+
+/**
+ * Autosize a JTextComponent to a given width.
+ * @param width the width
+ */
+fun JTextComponent.autoSize(width: Int): Unit {
+    val SLOP = 10
+    val dim = Dimension(width, width)
+    preferredSize = dim
+    size = dim
+    val r = modelToView(document.length)
+    preferredSize = Dimension(width, r.y + r.height + SLOP)
+}
+
+/**
+ * Make a shortcut for a menu item, using the standard combining key
+ * (control, command, etc.) for the system we're on.
+ * @param key KeyEvent constant describing the key
+ */
+fun JMenuItem.makeShortcut(key: Int): Unit {
+    val SC_KEY_MASK = Toolkit.getDefaultToolkit().menuShortcutKeyMask
+    setAccelerator(KeyStroke.getKeyStroke(key, SC_KEY_MASK))
+}
+
+/**
+ * Given a MenuElement object, get the item whose text matches the
+ * specified text.
+ * @param text to match
+ * @return first matched element, null if no match found
+ */
+fun MenuElement.getItem(name: String) : JMenuItem? {
+    subElements.forEach {
+        val jMenuItem = it.component as? JMenuItem
+        if (jMenuItem?.text == name) {
+            return jMenuItem
+        }
+    }
+    return null
+}
+
--- a/src/name/blackcap/clipman/Pasteboard.kt	Wed Jan 29 10:47:46 2020 -0800
+++ b/src/name/blackcap/clipman/Pasteboard.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -21,11 +21,6 @@
 /* Constants, etc. */
 val CHARSET_NAME = "UTF-8"
 
-/*
- * Represents an error dealing with pasteboard items.
- */
-class PasteboardError(): Exception()
-
 /**
  * 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 10:47:46 2020 -0800
+++ b/src/name/blackcap/clipman/PasteboardQueue.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -11,7 +11,7 @@
 import java.util.logging.Level
 import java.util.logging.Logger
 import javax.swing.*
-import javax.swing.text.JTextComponent
+import javax.swing.text.DefaultHighlighter
 
 /**
  * A queue that tracks the data we display and the widgets used to
@@ -56,7 +56,7 @@
      */
     @Synchronized fun add(item: QueueItem) {
         inSwingThread {
-            parent.add(item.component)
+            parent.add(item.view.contents)
             scrollPane?.run {
                 validate()
                 verticalScrollBar.run { value = maximum + 1 }
@@ -67,6 +67,16 @@
     }
 
     /**
+     * Delete something from the queue.
+     * @param object to delete
+     */
+    @Synchronized fun delete(item: QueueItem) {
+        if (queue.remove(item)) {
+            parent.validate()
+        }
+    }
+
+    /**
      * Find and highlight the next occurrence of the specified string
      * @param string to search
      * @param whether to search backwards (default forwards)
@@ -77,18 +87,68 @@
     fun find(needle: String, direction: Direction = Direction.FORWARDS,
         foldCase: Boolean = true, origin: Offset? = null): Offset?
     {
-        /* canonicalize the origin */
-        val norigin = if (origin == null) {
-            if (direction == Direction.FORWARDS) {
-                Offset(0, 0)
-            } else {
-                Offset(queue.size - 1, queue.last.searchable.document.length)
+        /* clean up any old highlights */
+        queue.forEach {
+            val hiliter = it.view.searchable.highlighter
+            hiliter.highlights.forEach {
+                hiliter.removeHighlight(it)
             }
-        } else {
-            origin
+        }
+
+        /* get starting item index */
+        val qMax = queue.size
+        var norigin = origin ?: when (direction) {
+            Direction.FORWARDS -> Offset(0, 0)
+            Direction.BACKWARDS -> Offset(qMax - 1, -1)
         }
 
-        /* XXX - not finished */
+        /* loop initialization */
+        val (start, incr, search) = if (direction == Direction.FORWARDS) {
+            Triple( 0,  1, { n: String, h: String, o: Int -> h.indexOf(n, o, foldCase) })
+        } else {
+            Triple(-1, -1, { n: String, h: String, o: Int -> h.lastIndexOf(n, o, foldCase) })
+        }
+        val painter = DefaultHighlighter.DefaultHighlightPainter(null);
+        var pos = -1
+
+        /* try and find it */
+        while (norigin.inQueue >= 0 && norigin.inQueue < qMax) {
+            val si = queue.get(norigin.inQueue).view.searchable
+            val doc = si.document
+            val text = doc.getText(0, doc.length)
+            pos = if (norigin.inItem >= 0) norigin.inItem else text.length - 1
+            pos = search(needle, text, pos)
+            if (pos >= 0) {
+                si.highlighter.addHighlight(pos, pos+needle.length, painter)
+                break
+            }
+            norigin = Offset(norigin.inQueue + incr, start)
+        }
+        return if (pos >= 0) Offset(norigin.inQueue, pos) else null
+    }
+
+    /**
+     * Ensure none of the searchables in this queue are selected.
+     */
+    fun deselectAll() {
+        queue.forEach {
+            val s = it.view.searchable as? ClipText
+            if (s != null && s.selected) {
+                s.selected = false
+                s.validate()
+            }
+        }
+    }
+
+    /**
+     * Return the selected item, or null if nothing has been selected
+     */
+    fun getSelected(): QueueItem? {
+        queue.forEach {
+            if ((it.view.searchable as? ClipText)?.selected ?: false) {
+                return it
+            }
+        }
         return null
     }
 
@@ -97,8 +157,13 @@
             var size = queue.size
             var dirty = false
             while (size > _maxSize) {
-                var extra = queue.removeFirst().component
-                inSwingThread { parent.remove(extra) }
+                var extra = queue.removeFirst().view
+                inSwingThread {
+                    if (extra.searchable.selected) {
+                        SelectionRequired.disable()
+                    }
+                    parent.remove(extra.contents)
+                }
                 dirty = true
                 size -= 1
             }
@@ -110,9 +175,9 @@
 }
 
 /**
- * An item in the above queue.
+ * An item in the above queue. Linking model to view here sorta violates
+ * MVC principles, but rules are sometimes best broken. Doing it this way
+ * makes it impossible for the view queue to fail to follow the data
+ * queue.
  */
-data class QueueItem(
-    val component: JComponent,
-    val searchable: JTextComponent,
-    val contents: PasteboardItem)
+data class QueueItem(val contents: PasteboardItem, val view: PasteboardItemView)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/PasteboardView.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -0,0 +1,104 @@
+/*
+ * View-related pasteboard stuff.
+ */
+package name.blackcap.clipman
+
+import java.awt.Color
+import java.awt.Dimension
+import javax.swing.*
+import javax.swing.border.*
+import javax.swing.text.html.StyleSheet
+import javax.swing.text.html.HTMLEditorKit
+
+/* border widths */
+val OUTER_BORDER_TOP = 3
+val OUTER_BORDER = 9
+val INNER_BORDER = 1
+val MARGIN_BORDER = 3
+
+/**
+ * What we use to display the text that is or was in the clipboard.
+ */
+class ClipText: JTextPane() {
+    private val normalBorder = CompoundBorder(LineBorder(Color.GRAY, INNER_BORDER),
+            EmptyBorder(MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER))
+    private val selectedBorder = CompoundBorder(LineBorder(Color.BLACK, INNER_BORDER+1),
+            EmptyBorder(MARGIN_BORDER-1, MARGIN_BORDER-1, MARGIN_BORDER-1, MARGIN_BORDER-1))
+    init {
+        border = normalBorder
+        setEditable(false)
+        alignmentX = JTextPane.LEFT_ALIGNMENT
+        resize()
+    }
+
+    /**
+     * We allow the text to be considered "selected;" such text is used
+     * as the target for any subsequent editing operation.
+     */
+    var selected: Boolean
+    get() {
+        return border === selectedBorder
+    }
+    set(value) {
+        border = if (value) { selectedBorder } else { normalBorder }
+    }
+
+    /**
+     * Refuse to fill the window in the vertical dimension, because doing
+     * so leads to a misleading display of the clipboard contents, falsely
+     * implying the existence of a bunch of trailing whitespace.
+     */
+    override fun getMaximumSize(): Dimension {
+        return Dimension(Int.MAX_VALUE, preferredSize.height)
+    }
+
+    /**
+     * Dynamically size or resize this view.
+     */
+    fun resize() {
+        autoSize(queue.v.parent.size.width -
+            2 * (PANEL_BORDER + OUTER_BORDER + INNER_BORDER + MARGIN_BORDER))
+    }
+}
+
+
+/**
+ * The stock HTMLEditorKit shares all style sheet data between all its
+ * instances. How unbelievably braindamaged. Correct that.
+ */
+class MyEditorKit: HTMLEditorKit() {
+    private var _styleSheet = defaultStyleSheet
+    override fun getStyleSheet() = _styleSheet
+    override fun setStyleSheet(value: StyleSheet) {
+        _styleSheet = value
+    }
+
+    /**
+     * Return the default style sheet that all HTMLEditorKit's come with.
+     */
+    val defaultStyleSheet: StyleSheet
+    get() {
+        return super.getStyleSheet()
+    }
+}
+
+/**
+ * Views are based on a JLabel and a ClipText (which we use for searching),
+ * wrapped in a JPanel that we use to display both.
+ */
+class PasteboardItemView(label: String, val searchable: ClipText) {
+    private val outerBorder =
+        MatteBorder(OUTER_BORDER_TOP, OUTER_BORDER, OUTER_BORDER, OUTER_BORDER,
+            queue.v.parent.background)
+
+    val contents = JPanel().apply {
+        layout = BoxLayout(this, BoxLayout.Y_AXIS)
+        background = queue.v.parent.background
+        border = outerBorder
+        add(JLabel(label).apply {
+            horizontalAlignment = JLabel.LEFT
+            alignmentX = JLabel.LEFT_ALIGNMENT
+        })
+        add(searchable)
+    }
+}
--- a/src/name/blackcap/clipman/RtfToHtml.kt	Wed Jan 29 10:47:46 2020 -0800
+++ b/src/name/blackcap/clipman/RtfToHtml.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -76,8 +76,8 @@
         val exitStatus = job.waitFor()
 
         /* after it exits, wait for our data consumers to exit */
-        outputConsumer.join();
-        errorConsumer.join();
+        outputConsumer.join()
+        errorConsumer.join()
 
         /* if it barfed, return an error, else return the HTML */
         if (exitStatus != 0) {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/SearchDialog.kt	Wed Jan 29 10:50:07 2020 -0800
@@ -0,0 +1,140 @@
+/*
+ * The dialog that controls a search.
+ */
+package name.blackcap.clipman
+
+import java.awt.Color
+import java.awt.Toolkit
+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
+
+class SearchDialog: JDialog(frame.v), ActionListener, DocumentListener {
+    /* the search term */
+    private val _searchFor = JTextField(50).also {
+        it.border = LineBorder(Color.GRAY, 1)
+        it.horizontalAlignment = JTextField.LEFT
+        it.alignmentX = JTextField.LEFT_ALIGNMENT
+        it.text = ""
+        it.document.addDocumentListener(this)
+    }
+    val searchFor: String
+    get() {
+        return _searchFor.text
+    }
+
+    /* whether or not we should ignore case */
+    private val _ignoreCase = JCheckBox("Ignore case", true)
+    val ignoreCase: Boolean
+    get() {
+        return _ignoreCase.isSelected()
+    }
+
+    /* whether or not searches should wrap around */
+    private val _autoWrap = JCheckBox("Auto wrap", false)
+    val autoWrap: Boolean
+    get() {
+        return _autoWrap.isSelected()
+    }
+
+    /* which direction to search */
+    private val _forwards = JRadioButton("Forward", true)
+    private val _backwards = JRadioButton("Backward", false)
+    private val _direction = ButtonGroup().apply {
+        add(_forwards)
+        add(_backwards)
+    }
+    val direction: PasteboardQueue.Direction
+    get() {
+        if (_forwards.isSelected()) {
+            return PasteboardQueue.Direction.FORWARDS
+        }
+        if (_backwards.isSelected()) {
+            return PasteboardQueue.Direction.BACKWARDS
+        }
+        throw RuntimeException("impossible button state!")
+    }
+
+    /* where to begin searching from. unlike the other properties, this
+       one is read/write. null means to start from the beginning on
+       forward searches, and from the end on backward searches (i.e.
+       search everything) */
+    var origin: PasteboardQueue.Offset? = null
+
+    private val _find = JButton("Find").also {
+        it.actionCommand = "Find"
+        it.addActionListener(this)
+    }
+
+    /* initializer */
+    init {
+        title = "Find"
+        contentPane.apply {
+            add(Box(BoxLayout.Y_AXIS).apply {
+                add(Box(BoxLayout.Y_AXIS).apply {
+                    add(JLabel("Search for").apply {
+                        horizontalAlignment = JLabel.LEFT
+                        alignmentX = JLabel.LEFT_ALIGNMENT
+                    })
+                    add(_searchFor)
+                })
+                add(Box(BoxLayout.X_AXIS).apply {
+                    add(Box(BoxLayout.Y_AXIS).apply {
+                        add(_ignoreCase)
+                        add(_autoWrap)
+                    })
+                    add(Box(BoxLayout.Y_AXIS).apply {
+                        add(_forwards)
+                        add(_backwards)
+                    })
+                })
+                add(_find)
+            })
+        }
+        rootPane.setDefaultButton(_find)
+    }
+
+    override fun actionPerformed(e: ActionEvent) {
+        if (e.actionCommand == "Find") {
+            setVisible(false)
+            find()
+        }
+    }
+
+    override fun setVisible(visible: Boolean) {
+        if (visible) {
+            _searchFor.run {
+                requestFocusInWindow()
+                selectAll()
+            }
+        }
+        super.setVisible(visible)
+    }
+
+    fun find(): Unit {
+        fun doFind(o: PasteboardQueue.Offset?) = queue.v.find(searchFor,
+            direction = direction, foldCase = ignoreCase, origin = o)
+        var result = doFind(origin)
+        if (result == null && origin != null && autoWrap) {
+            result = doFind(null)
+        }
+        if (result == null) {
+            Toolkit.getDefaultToolkit().beep()
+        }
+        origin = result
+    }
+
+    /* changing the search string resets the search origin */
+    override fun changedUpdate(e: DocumentEvent) {
+        if (e.document === _searchFor.document) {
+            origin = null
+        }
+    }
+    override fun insertUpdate(e: DocumentEvent) = changedUpdate(e)
+    override fun removeUpdate(e: DocumentEvent) = changedUpdate(e)
+}
+
+val searchDialog = SearchDialog()
\ No newline at end of file