diff --git a/Base.hs b/Base.hs
--- a/Base.hs
+++ b/Base.hs
@@ -13,6 +13,7 @@
 import Control.Monad as X
 import Data.ByteString as X (ByteString)
 import Data.Char as X
+import Data.Either as X
 import Data.Fixed as X
 import Data.Foldable as X
 import Data.Functor as X hiding (unzip)
@@ -30,7 +31,6 @@
 import System.IO.Unsafe as X
 import Text.Printf as X
 import Text.Read as X (readMaybe)
-import Text.Regex.Posix (match, makeRegexOptsM, compIgnoreCase, compExtended)
 
 import System.Clock
 
@@ -50,41 +50,15 @@
 (!?) :: [a] -> Int -> Maybe a
 xs !? n = listToMaybe $ drop n xs
 
-
--- API for searching
-matches :: ByteString -> ByteString -> Bool
-
--- Layer allowing switching back end
-
-{-
--- pcre-light version (can't use currently due to pcre3 dep)
-matches s = case compileM s [caseless, utf8] of
-    Right p -> \t -> isJust $ match p t []
-    _       -> const False
--}
-
-{-
--- regex-pcre2 version (fails to build in CI, not in Stackage)
-matches s = case makeRegexOptsM compCaseless 0 s of
-    Just p -> match p
-    _      -> const False
--}
-
-{-
--- pcre2 version (inefficient, mass Text conversion, ugly)
--- needs text dep/import
-matches s =
-    let p = decodeUtf8Lenient s
-    in \t -> unsafePerformIO
-        $ handle @SomeException (const $ pure False) $ evaluate
-        $ matchesOpt Caseless p (decodeUtf8Lenient t)
--}
+-- | Zipper structure, representing a list with a cursor.
+data Zipper a = Zipper { cur :: !a, back :: ![a], front :: ![a] }
 
--- regex-posix version (reputed to be slow and buggy)
-matches s = maybe (const False) match $
-    makeRegexOptsM (compIgnoreCase + compExtended) 0 s
+zipEdit :: (a -> a) -> Zipper a -> Zipper a
+zipEdit f z = z { cur = f z.cur }
 
--- not yet tried:
--- regex-tdfa (mass Text conversion, parsec dep) text import
--- regex-dfa (not in Stackage, unknown engine)
+zipUp, zipDown :: Zipper a -> Zipper a
+zipUp   (Zipper c (nx:rest) f)  = Zipper nx rest (c:f)
+zipUp   z                       = z
+zipDown (Zipper c b (pv:rest))  = Zipper pv (c:b) rest
+zipDown z                       = z
 
diff --git a/Config.hs b/Config.hs
deleted file mode 100644
--- a/Config.hs
+++ /dev/null
@@ -1,75 +0,0 @@
--- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
--- Copyright (c) 2019-2021, 2026 Galen Huntington
--- SPDX-License-Identifier: GPL-2.0-or-later
-
-module Config where
-
-import Data.Map qualified as M
-
-import Base
-import Style
-import Paths_hmp3_ng (version)
-
--- XXX some styles are currently unused, but planned is a CLI option to select
-
-fixedStyles :: M.Map String UIStyle
-fixedStyles = M.fromList
-    [ ("default", defaultStyle)
-    , ("dark",    defaultStyle)
-    , ("light",   lightBgStyle)
-    , ("mono",    bwStyle)
-    , ("mutt",    muttStyle)
-    ]
-
-defaultStyle :: UIStyle
-defaultStyle  = UIStyle { window     = style "default"      "default"
-                        , titlebar   = style "brightwhite"  "green"
-                        , selected   = style "blue"         "default"
-                        , cursors    = style "black"        "cyan"
-                        , combined   = style "brightwhite"  "cyan"
-                        , warnings   = style "red"          "default"
-                        , modals     = style "black"        "white"
-                        , blockcursor= style "black"        "red"
-                        , progress   = style "cyan"         "white" }
-
--- | A style more suitable for light backgrounds
-lightBgStyle :: UIStyle
-lightBgStyle =
-           defaultStyle { selected   = style "darkblue"     "default"
-                        , warnings   = style "darkred"      "default" }
-
---
--- | Another style for dark backgrounds, reminiscent of mutt
---
-muttStyle :: UIStyle
-muttStyle = UIStyle { window     = style "brightwhite"  "black"
-                    , titlebar   = style "green"        "blue"
-                    , selected   = style "brightwhite"  "black"
-                    , cursors    = style "black"        "cyan"
-                    , combined   = style "black"        "cyan"
-                    , warnings   = style "brightwhite"  "red"
-                    , modals     = style "black"        "cyan"
-                    , blockcursor= style "black"        "darkred"
-                    , progress   = style "cyan"         "white"  }
-
-bwStyle :: UIStyle
-bwStyle = UIStyle {
-        window      = style "default"     "default"
-       ,titlebar    = style "reverse"     "reverse"
-       ,selected    = style "brightwhite" "default"
-       ,cursors     = style "reverse"     "reverse"
-       ,combined    = style "reverse"     "reverse"
-       ,warnings    = style "reverse"     "reverse"
-       ,modals      = style "reverse"     "reverse"
-       ,blockcursor = style "reverse"     "reverse"
-       ,progress    = style "reverse"     "reverse"
-    }
-
-------------------------------------------------------------------------
-
-package :: String
-package = "hmp3-ng"
-
-versinfo :: String
-versinfo  = package ++ " v" ++ showVersion version
-
diff --git a/Core.hs b/Core.hs
--- a/Core.hs
+++ b/Core.hs
@@ -8,18 +8,17 @@
 module Core (
     Options(..),
     start, shutdown,
-    seekLeft, seekRight, upOne, downOne, pause, nextMode, playNext, playPrev,
+    upOne, downOne, pause, nextMode, playNext, playPrev,
     forcePause, putMessage, clearMessage, playCursor, playCur,
-    jumpToPlaying, jump, jumpRel,
+    jumpToPlaying, jump, jumpRel, jumpRandom,
     upPage, downPage,
-    seekStart,
+    seek, seekStart, adjFolderCol,
     blacklist,
     setsModal, closeModal, showHist,
-    jumpToMatchDir, jumpToMatchFile,
+    search, repeatSearch,
     toggleFocus, jumpToNextDir, jumpToPrevDir,
     loadConfig,
     discardErrors,
-    showTimeDiff_,
 ) where
 
 import Base
@@ -28,7 +27,9 @@
 import State
 import Style
 import Playlist
+import Text (matches)
 import UI qualified
+import Elements qualified as El
 
 import Data.ByteString.Char8 qualified as P
 import Data.Sequence qualified as Seq
@@ -36,89 +37,90 @@
 import Data.Array               ((!), Array)
 import Data.Proxy
 import Data.Tuple               (swap)
+import Control.Monad.Except
 import Control.Monad.State.Strict
 import System.Directory         (doesFileExist, findExecutable, createDirectoryIfMissing,
                                  getXdgDirectory, XdgDirectory(..))
 import System.IO                (hPutStrLn, stderr)
 import System.Process           (runInteractiveProcess, waitForProcess)
-import System.Clock             (TimeSpec(..), diffTimeSpec)
 import System.Random            (randomR, newStdGen)
-import System.FilePath          ((</>))
-import System.Posix.FilePath    (takeFileName)
-
+import System.FilePath qualified as FP ((</>))
+import System.Posix.FilePath    (takeFileName, (</>))
 import System.Posix.Process     (exitImmediately)
 
 
-mp3Tool :: String
-mp3Tool = "mpg123"
-
 ------------------------------------------------------------------------
 
 -- | Command-line configuration.
 data Options = Options
-    { optPaused     :: !Bool             -- ^ start in a paused state
-    , optConfigPath :: !(Maybe FilePath) -- ^ override the style.conf location
-    , optPlayMode   :: Maybe Mode        -- ^ play mode
-    , optHistSize   :: Int               -- ^ history size
+    { paused     :: !Bool             -- ^ start in a paused state
+    , configPath :: !(Maybe FilePath) -- ^ override the style.conf location
+    , playMode   :: Maybe Mode        -- ^ play mode
+    , histSize   :: Int               -- ^ history size
+    , random     :: Bool              -- ^ start on random song
     }
 
 -- | Sets up state, spawns sub-threads, and starts player.
 start :: Options -> Playlist -> IO ()
 start opts (Playlist folders music) = do
 
-    config <- catch @SomeException UI.start \err -> do
-        -- An uncaught exception here would deadlock.
-        -- XXX more state model revisions should obviate need
-        hPutStrLn stderr $ "Curses failed to start: " ++ show err
-        exitImmediately $ ExitFailure 1
+    uiStyle <- UI.start
     bootTime <- getMonoTime
-    let size = length music
-    mode <- maybe readState pure (optPlayMode opts)
+    mode <- maybe readState pure opts.playMode
     gen <- newStdGen
-    let (current, randomGen) =
-            if mode == Random then randomR (0, size-1) gen else (0, gen)
-
-    threads <- traverse forkIO
-        [ mpgLoop
-        , mpgInput
-        , refreshLoop
-        , uptimeLoop
-        ]
+    let (current, randomGen) = if mode == Random || opts.random
+        then randomR (0, length music - 1) gen else (0, gen)
 
     putMVar hState HState
         { music
         , folders
-        , size
         , bootTime
-        , configPath   = optConfigPath opts
+        , configPath   = opts.configPath
         , current
         , cursor       = current
         , randomGen
         , mode
-        , config
-        , threads
+        , uiStyle
         , spawns       = 0
-        , mpgPid       = Nothing
         , clock        = Nothing
         , info         = Nothing
         , id3          = Nothing
         , modal        = Nothing
         , playHist     = mempty
         , searchHist   = []
-        , searchFw     = True
-        , histSize     = optHistSize opts
+        , searchType   = SearchType True True
+        , folderCol    = 0.334
+        , histSize     = opts.histSize
         , miniFocused  = False
-        , exiting      = False
         , status       = Stopped
-        , minibuffer   = Fast mempty defaultSty
+        , minibuffer   = []
         , uptime       = mempty
         }
 
     loadConfig  -- TODO this should return config rather than setting it
 
-    playCur
-    when (optPaused opts) pause -- TODO use LOADPAUSED?
+    traverse_ forkIO
+        [ mpgLoop
+        , mpgInput
+        , refreshLoop
+        , uptimeLoop
+        ]
 
+    -- Poll for a second for process to start, then play.
+    let go :: Int -> IO ()
+        go 0 = silentlyModifyHS \st -> st { spawns = 1 }
+        go n = do
+            ready <- isJust <$> readIORef mpgRef
+            if ready
+                then do
+                    playCur
+                    when opts.paused pause -- TODO use LOADPAUSED?
+                else do
+                    threadDelay 20_000
+                    go (n-1)
+    go 50
+
+
 ------------------------------------------------------------------------
 
 -- | Uniform loop and thread handler
@@ -143,49 +145,34 @@
 
 ------------------------------------------------------------------------
 
--- | Process loop, launch mpg123, set the handles in the state
--- and then wait for the process to die. If it does, restart it.
---
--- If we're unable to start at all, we should say something sensible
--- For example, if we can't start it two times in a row, perhaps give up?
---
+-- | Loop, launching decoder and updating global state.
 mpgLoop :: IO ()
 mpgLoop = runForever do
-    mmpg <- findExecutable mp3Tool
-    case mmpg of
-      Nothing     -> shutdown $ Just $ "Cannot find " ++ mp3Tool ++ " in path"
-      Just mppath -> do
-        mv <- try $ runInteractiveProcess mppath ["-R", "--remote-err"] Nothing Nothing
-        case mv of
-          Left (ex :: SomeException) ->
-            warnA $ mppath ++ " failed to start; retrying: " ++ show ex
-
-          Right (writeh, _, errh, pid) -> do
-            ct <- modifyHS $ \st -> let sp = spawns st + 1 in (st
-                { mpgPid    = Just pid
-                , status    = Stopped
+    empg <- runExceptT do
+        mppath <- lift (findExecutable mp3Tool) >>=
+            maybe (throwError $ "Cannot find " ++ mp3Tool ++ " in path") pure
+        lift (try @SomeException $
+            runInteractiveProcess mppath ["-R", "--remote-err"] Nothing Nothing
+            ) >>= flip either pure \ex ->
+                throwError $ mp3Tool ++ " failed to start; retrying: " ++ show ex
+    case empg of
+        Left err -> do
+            warnA err
+            -- Hackily count failed initial spawn, for Ready message
+            silentlyModifyHS \st -> st { spawns = st.spawns `max` 1 }
+            threadDelay 20_000_000  -- longer wait after these errors
+        Right handles -> do
+            ct <- modifyHS $ \st -> let sp = st.spawns + 1 in (st
+                { status    = Stopped
                 , info      = Nothing
                 , id3       = Nothing
                 , spawns    = sp
                 }, sp)
-
-            putMVar mpg Mpg { errh, writeh }
-
             when (ct > 1) $ warnA $ mp3Tool ++ " #" ++ show ct ++ ": Ready"
-            catch @SomeException (void $ waitForProcess pid) (const $ pure ())
-
-            -- Must be in this order or risk shutdown deadlock!
-            silentlyModifyHS $ \st -> st { mpgPid = Nothing }
-            void $ takeMVar mpg
-
-            stop <- getsHS exiting
-            when stop exitSuccess
+            overseeMpg handles
             threadDelay 1_000_000  -- let threads spit errors
-            warnA $ "Restarting " ++ mppath ++ " ..."
-
-        -- Slow spawn loops in case of trouble.
-        threadDelay 4_000_000
-
+            warnA $ "Restarting " ++ mp3Tool ++ " ..."
+    threadDelay 4_000_000  -- rate-limit respawns
 
 ------------------------------------------------------------------------
 
@@ -194,124 +181,103 @@
 refreshLoop :: IO ()
 refreshLoop = runForever $ takeMVar modified *> UI.refresh
 
-
 ------------------------------------------------------------------------
 
 -- | The clock ticks once per minute, but check more often in case of drift.
 uptimeLoop :: IO ()
-uptimeLoop = runForever $ do
+uptimeLoop = runForever do
     now <- getMonoTime
-    modifyHS_ $ \st -> st { uptime = showTimeDiff (bootTime st) now }
-    threadDelay 3_000_000
-
-------------------------------------------------------------------------
-
-showTimeDiff_ :: Bool -> TimeSpec -> TimeSpec -> ByteString
-showTimeDiff_ secs before now
-    | ms == 0 && secs
-              = go ""
-    | hs == 0 = go $ printf "%dm" m
-    | d == 0  = go $ printf "%dh%02dm" h m
-    | True    = go $ printf "%dd%02dh%02dm" d h m
-    where
-        go     = P.pack . ss
-        stot   = sec $ diffTimeSpec before now
-        (ms,s) = quotRem stot 60
-        (hs,m) = quotRem ms 60
-        (d,h)  = quotRem hs 24
-        ss     = if secs then (<> printf (if ms > 0 then "%02ds" else "%ds") s) else id
-
-showTimeDiff :: TimeSpec -> TimeSpec -> ByteString
-showTimeDiff = showTimeDiff_ False
+    μs <- modifyHS \st -> let diff = now - st.bootTime in
+        (st { uptime = El.showDuration False diff }, diff `div` 1000)
+    threadDelay $ fromIntegral $ let m = 60_000_000 in m - μs `mod` m
 
 ------------------------------------------------------------------------
 
 -- | Handle messages arriving over a pipe from the decoder process. When
--- shutdown kills the other end of the pipe, hGetLine will fail, so we
--- take that chance to exit.
---
+-- shutdown kills the other end of the pipe, hGetLine will fail.
 mpgInput :: IO ()
 mpgInput = runForever $ do
-    line <- P.hGetLine =<< errh <$> readMVar mpg
+    line <- P.hGetLine =<< readMVar mpgRead
     case mpgParser line of
         Right m       -> handleMsg m
-        Left (Just e) -> warnA ("mpg123: " ++ e)
+        Left (Just e) -> warnA (mp3Tool ++ ": " ++ e)
         _             -> pure ()
 
 ------------------------------------------------------------------------
 
 -- | Close most things. Important to do all the jobs:
--- TODO maybe releaseSignals here?
 shutdown :: Maybe String -> IO ()
 shutdown ms = do
     UI.end
-    silentlyModifyHS $ \st -> st { exiting = True }
-    discardErrors writeState
-    mpid <- getsHS mpgPid
-    whenJust mpid \pid -> do
-        discardErrors $ sendMpg Quit
-        void $ waitForProcess pid
+    mpg <- readIORef mpgRef
+    whenJust mpg \Mpg { mpgPH } -> do
+        discardErrors writeState
+        void $ sendMpg' Quit
+        void $ waitForProcess mpgPH
     exitImmediately =<< case ms of
         Just s -> hPutStrLn stderr s *> pure (ExitFailure 1)
         _      -> pure ExitSuccess
 
 ------------------------------------------------------------------------
--- 
--- Write incoming messages from the encoder to the global state in the
--- right pigeon hole.
---
-handleMsg :: Msg -> IO ()
-
-handleMsg (S i)   = modifyHS_ $ \s -> s { info = Just i }
-
-handleMsg (I id3) = modifyHS_ $ \s -> s { id3 = Just id3 }
-
-handleMsg (P t) = do
-    modifyHS_ $ \s -> s { status = t }
-    when (t == Stopped) playNext   -- transition to next song
+-- Process incoming messages from the decoder.
 
+handleMsg :: Msg -> IO ()
+handleMsg (S i)   = modifyHS_ \st -> st { info = Just i }
+handleMsg (I id3) = modifyHS_ \st -> st { id3 = Just id3 }
+handleMsg (P t)   = do
+    modifyHS_ \st -> st
+        { status = t
+        , clock = case st.clock of -- push stopped clock towards end
+            Just fr | t == Stopped -> Just $ adjFrame 0.1 fr
+            c -> c
+        }
+    when (t == Stopped) playNext
 handleMsg (F f) = do
     silentlyModifyHS \st -> st { clock = Just f }
     UI.refreshClock
 
 ------------------------------------------------------------------------
---
 -- Basic operations
---
 
--- | Seek backward in song
-seekLeft :: IO ()
-seekLeft = seek \g -> max 0 (currentFrame g - 400)
-
--- | Seek forward in song
-seekRight :: IO ()
-seekRight = seek \g -> currentFrame g + min 400 (framesLeft g)
+adjFrame :: Fixed E2 -> Frame -> Frame
+adjFrame s fr = Frame { elapsed = fr.elapsed + s', left = fr.left - s' }
+    where s' = (s `min` fr.left) `max` (- fr.elapsed)
 
 seekStart :: IO ()
-seekStart = seek $ const 0
+seekStart = seek $ fromIntegral $ minBound @Int
 
--- | Generic seek
-seek :: (Frame -> Int) -> IO ()
-seek fn = do
-    mfr <- getsHS clock
-    whenJust mfr \fr -> sendMpg $ Jump $ fn fr
+-- | Seek in relative seconds
+seek :: Fixed E2 -> IO ()
+seek s = do
+    mss <- modifyHS \st -> case st.clock of
+        Just fr -> let fr' = adjFrame s fr
+                   in (st { clock = Just fr' }, Just fr'.elapsed)
+        Nothing -> (st, Nothing)
+    whenJust mss $ sendMpg . Jump
 
+adjFolderCol :: Int -> IO ()
+adjFolderCol adj = do
+    (_, sz) <- UI.screenSize
+    modifyHS_ \st -> st { folderCol =
+        let fc' = st.folderCol + fromIntegral adj / fromIntegral sz
+        in (fc' `max` 0) `min` 1 }
+
 ------------------------------------------------------------------------
 
 -- | Generic jump
 jumpFn :: (Int -> Int) -> IO ()
 jumpFn fn = modifyHS_ \st ->
-    st { cursor = (fn (cursor st) `min` (size st - 1)) `max` 0 }
+    st { cursor = (fn st.cursor `min` (st.size - 1)) `max` 0 }
 
 -- | Move cursor up or down
 upOne, downOne :: IO ()
 upOne   = jumpFn (subtract 1)
-downOne = jumpFn (+1)
+downOne = jumpFn (+ 1)
 
 page :: Int -> IO ()
 page dir = do
     (sz, _) <- UI.screenSize
-    jumpFn (+ dir*(sz-5))
+    jumpFn (+ dir*(1`max`(sz-5)))
 
 upPage, downPage :: IO ()
 upPage   = page (-1)
@@ -325,15 +291,15 @@
 jumpRel :: Rational -> IO ()
 jumpRel r | r < 0 || r >= 1 = pure ()
           | True = modifyHS_ $ \st ->
-              st { cursor = floor $ fromIntegral (size st) * r }
+              st { cursor = floor $ fromIntegral st.size * r }
 
 -- | Experimental feature concept.
 blacklist :: IO ()
 blacklist = do
     st <- getsHS id
     appendFile ".hmp3-delete" . (++"\n") . P.unpack $
-        let fe = music st ! cursor st
-        in P.intercalate (P.singleton '/') [dname $ folders st ! fdir fe, fbase fe]
+        let fe = st.music ! st.cursor
+        in (st.folders ! fe.fdir).dname </> fe.fbase
 
 ------------------------------------------------------------------------
 
@@ -348,20 +314,20 @@
 
 -- | Play the song under the cursor (from the start)
 playCur :: IO ()
-playCur = runPlayOp $ Just <$> gets cursor
+playCur = runPlayOp $ Just <$> gets (.cursor)
 
 -- | Play the song before the current song, if we're not at the beginning
 -- If we're at the beginning, and loop mode is on, then loop to the end
 -- If we're in random mode, play the next random track
 playPrev :: IO ()
 playPrev = runPlayOp do
-    HState { mode, size, current } <- get
-    case mode of
+    st <- get
+    case st.mode of
         Random  -> playRandomOp
         Single  -> pure Nothing
-        _ | current > 0
-                -> pure $ Just $ current - 1
-        Loop    -> pure $ Just $ size - 1
+        _ | st.current > 0
+                -> pure $ Just $ st.current - 1
+        Loop    -> pure $ Just $ st.size - 1
         Once    -> pure Nothing
 
 -- | Play the song following the current song, if we're not at the end
@@ -372,24 +338,35 @@
 
 playNextOp :: PlayOp
 playNextOp = do
-    HState { mode, current, size } <- get
-    let next = current + 1
-    case mode of
+    st <- get
+    let next = st.current + 1
+    case st.mode of
         Random  -> playRandomOp
         Single  -> pure Nothing
-        _ | next < size
+        _ | next < st.size
                 -> pure $ Just next
         Loop    -> pure $ Just 0
         Once    -> pure Nothing
 
+-- | Generate a random song
+getRandom :: State HState Int
+getRandom = do
+    st <- get
+    let (new, gen') = randomR (0, st.size - 1) st.randomGen
+    put $ st { randomGen = gen' }
+    pure new
+
 -- | Random song
 playRandomOp :: PlayOp
-playRandomOp = do
-    HState { size, randomGen } <- get
-    let (new, gen') = randomR (0, size-1) randomGen
-    modify' \st -> st { randomGen = gen' }
-    pure $ Just new
+playRandomOp = Just <$> getRandom
 
+-- | Jump to random song
+jumpRandom :: Bool -> IO ()
+jumpRandom play = runPlayOp do
+    cursor <- getRandom
+    modify' \st -> st { cursor }
+    pure $ if play then Just cursor else Nothing
+
 -- | Generic next song selection
 -- If cursor is on current, drag it along.
 runPlayOp :: PlayOp -> IO ()
@@ -400,14 +377,14 @@
         forM mnew \new -> do
             HState { .. } <- get
             let fe = music ! new
-                f  = P.intercalate (P.singleton '/')
-                        [dname $ folders ! fdir fe, fbase fe]
+                f  = (folders ! fe.fdir).dname </> fe.fbase
             modify' \st -> st
-                { current = new
-                , status  = Playing
-                , cursor  = if current == cursor then new else cursor
+                { current  = new
+                , status   = Playing
+                , cursor   = if current == cursor then new else cursor
                 , playHist = Seq.take histSize $ (now, new) <| playHist
-                , id3     = Nothing
+                , id3      = Nothing
+                , clock    = Nothing
                 }
             pure f
     forM_ mfile $ sendMpg . Load
@@ -421,14 +398,14 @@
 -- | Always pause
 forcePause :: IO ()
 forcePause = do
-    st <- getsHS status
+    st <- getsHS (.status)
     when (st == Playing) pause
 
 ------------------------------------------------------------------------
 
 -- | Move cursor to currently playing song
 jumpToPlaying :: IO ()
-jumpToPlaying = modifyHS_ $ \st -> st { cursor = current st }
+jumpToPlaying = modifyHS_ $ \st -> st { cursor = st.current }
 
 -- | Move cursor to first song in next directory (or wrap)
 jumpToNextDir, jumpToPrevDir :: IO ()
@@ -438,49 +415,51 @@
 -- | Generic jump to dir
 jumpToDir :: (Int -> Int -> Int) -> IO ()
 jumpToDir fn = modifyHS_ \st ->
-    let i   = fdir (music st ! cursor st)
-        d   = fn i (length $ folders st)
-    in st { cursor = dlo (folders st ! d) }
+    let i   = (st.music ! st.cursor).fdir
+        d   = fn i (length st.folders)
+    in st { cursor = (st.folders ! d).dlo }
 
 ------------------------------------------------------------------------
 
 -- a bit of bounded parametric polymorphism so we can abstract over record selectors
 -- in the regex search stuff below
-class Lookup a       where extract :: a -> RawFilePath
-instance Lookup Dir  where extract = takeFileName . dname
-instance Lookup File where extract = fbase
+class Lookup a       where extract :: a -> ByteString
+instance Lookup Dir  where extract = takeFileName . (.dname)
+instance Lookup File where extract = (.fbase)
 
-jumpToMatchFile :: Maybe String -> Bool -> IO ()
-jumpToMatchFile re sw = genericJumpToMatch re sw k sel
-    where k st = (music st, cursor st, size st)
-          sel i _ = i
+setSearchErr :: HState -> ByteString -> HState
+setSearchErr st err = st { minibuffer = [plainSeg err] }
 
-jumpToMatchDir :: Maybe String -> Bool -> IO ()
-jumpToMatchDir re sw = genericJumpToMatch re sw k sel
-    where k st = (folders st, fdir (music st ! cursor st), length $ folders st)
-          sel i st = dlo (folders st ! i)
+search :: SearchType -> ByteString -> IO ()
+search typ pat = modifyHS_ \st ->
+    dispatchSearch (st { searchType = typ }) pat typ
 
-genericJumpToMatch :: Lookup a
-                   => Maybe String
-                   -> Bool
-                   -> (HState -> (Array Int a, Int, Int))
-                   -> (Int -> HState -> Int)
-                   -> IO ()
-genericJumpToMatch re sw k sel = do
-    found <- modifyHS \st -> let
-        info = case re of
-            Just s -> Just (st { searchFw = sw }, s, sw)
-            _      -> listToMaybe [ (st, s, searchFw st == sw) | s <- searchHist st ]
-        in flip (maybe (st, False)) info \(st', p, forwards) -> do
-            let (fs, cur, m) = k st
-                l = if forwards then [cur+1 .. m-1] ++ [0 .. cur]
-                                else [cur-1, cur-2 .. 0] ++ [m-1, m-2 .. cur]
-                match = matches (P.pack p)
-            case [ i | i <- l, match $ extract (fs ! i) ] of
-                i:_ -> (st' { cursor = sel i st }, True)
-                _   -> (st', False)
-    unless found $ putMessage $ Fast "No match found." defaultSty
+repeatSearch :: Bool -> IO ()
+repeatSearch same = modifyHS_ \st -> case st.searchHist of
+    pat : _ -> dispatchSearch st pat
+        st.searchType { isForwards = st.searchType.isForwards == same }
+    _       -> setSearchErr st "No previous search."
 
+dispatchSearch :: HState -> ByteString -> SearchType -> HState
+dispatchSearch st pat typ =
+    either (setSearchErr st) (\i -> st { cursor = i }) case typ of
+        SearchType True fw ->
+            genericMatch pat fw st.music st.cursor st.size
+        SearchType False fw -> do
+            j <- genericMatch pat fw st.folders (st.music ! st.cursor).fdir
+                $ length st.folders
+            pure (st.folders ! j).dlo
+
+genericMatch :: Lookup a => ByteString -> Bool -> Array Int a -> Int -> Int
+    -> Either ByteString Int
+genericMatch pat fw fs cur sz = do
+    let l = if fw then [cur+1 .. sz-1] ++ [0 .. cur]
+                  else [cur-1, cur-2 .. 0] ++ [sz-1, sz-2 .. cur]
+    match <- maybe (Left "Invalid ERE search pattern.") Right $ matches pat
+    case [ i | i <- l, match $ extract (fs ! i) ] of
+        i : _ -> Right i
+        _     -> Left "No match found."
+
 ------------------------------------------------------------------------
 
 -- | General modal setting.
@@ -496,16 +475,16 @@
 showHist = do
     now <- getMonoTime
     setsModal \st -> Just $ HistModal [
-        (showTimeDiff_ True tm now, (ix, fbase $ music st ! ix))
-            | (tm, ix) <- toList $ playHist st ]
+        (El.showDuration True (now - tm), (ix, (st.music ! ix).fbase))
+            | (tm, ix) <- toList st.playHist ]
 
 -- | Focus the minibuffer
 toggleFocus :: IO ()
-toggleFocus = modifyHS_ $ \st -> st { miniFocused = not (miniFocused st) }
+toggleFocus = modifyHS_ $ \st -> st { miniFocused = not st.miniFocused }
 
 -- | Toggle the mode flag
 nextMode :: IO ()
-nextMode = modifyHS_ $ \st -> st { mode = next (mode st) } where
+nextMode = modifyHS_ $ \st -> st { mode = next st.mode } where
     next v = if v == maxBound then minBound else succ v
 
 ------------------------------------------------------------------------
@@ -518,14 +497,14 @@
 writeState = do
     dir <- getStatePath
     createDirectoryIfMissing True dir
-    mode <- getsHS mode
-    writeFile (dir </> "mode") $ show mode ++ "\n"
+    mode <- getsHS (.mode)
+    writeFile (dir FP.</> "mode") $ show mode ++ "\n"
 
 -- | Read mode state
 readState :: IO Mode
 readState = do
     dir <- getStatePath
-    let f = dir </> "mode"
+    let f = dir FP.</> "mode"
     b <- doesFileExist f
     modeM <- if b
         then readMaybe <$!> readFile f
@@ -537,11 +516,11 @@
 --
 
 getConfPath :: IO FilePath
-getConfPath = getXdgDirectory XdgConfig $ "hmp3" </> "style.conf"
+getConfPath = getXdgDirectory XdgConfig $ "hmp3" FP.</> "style.conf"
 
 loadConfig :: IO ()
 loadConfig = do
-    f <- maybe getConfPath pure =<< getsHS configPath
+    f <- maybe getConfPath pure =<< getsHS (.configPath)
     b <- doesFileExist f
     if b then do
         str' <- readFile f
@@ -557,7 +536,7 @@
             Just rsty -> do
                 let sty = buildStyle rsty
                 initcolours sty
-                modifyHS_ $ \st -> st { config = sty }
+                modifyHS_ $ \st -> st { uiStyle = sty }
     else
         pure () -- TODO in some cases show a warning
     UI.resetui
@@ -565,14 +544,14 @@
 ------------------------------------------------------------------------
 -- Set the minibuffer
 
-putMessage :: StringA -> IO ()
+putMessage :: Line -> IO ()
 putMessage s = modifyHS_ \st -> st { minibuffer = s }
 
 clearMessage :: IO ()
-clearMessage = putMessage $ Fast P.empty defaultSty
+clearMessage = putMessage []
 
 warnA :: String -> IO ()
 warnA x = do
-    sty <- getsHS config
-    putMessage $ Fast (P.pack x) (warnings sty)
+    sty <- getsHS (.uiStyle.warnings)
+    putMessage [Seg sty (P.pack x)]
 
diff --git a/Decoder.hs b/Decoder.hs
--- a/Decoder.hs
+++ b/Decoder.hs
@@ -5,66 +5,55 @@
 -- Wire protocol for mpg123
 
 module Decoder (
-    mpgParser, Cmd(..), cmdToBS,
+    mp3Tool, mpgParser, Cmd(..), cmdToBS,
     Msg(..), Id3(..), Status(..), Frame(..),
 ) where
 
 import Base
+import Text (trim, readIntM, showInt, guessEncoding)
 
 import Data.ByteString.Char8 qualified as P
-import Data.ByteString.UTF8 qualified as UTF8
 
+
+mp3Tool :: IsString a => a
+mp3Tool = "mpg123"
+
 ------------------------------------------------------------------------
 -- Send commands to mpg123
 
-data Cmd = Load ByteString | Jump Int | Pause | Quit
+data Cmd = Load !ByteString | Jump !(Fixed E2) | Pause | Quit
 
 cmdToBS :: Cmd -> ByteString
 cmdToBS (Load f) = "L " <> f
-cmdToBS (Jump i) = "J " <> P.pack (show i) -- can be relative with +/-; not used here
+cmdToBS (Jump s) = "J " <> (P.pack . show) s <> "s"
 cmdToBS Pause    = "P"  -- (un)pauses
 cmdToBS Quit     = "Q"
 
 ------------------------------------------------------------------------
 -- Receive messages from mpg123
 
-data Msg = I                !Id3
-         | S {-# UNPACK #-} !ByteString
-         | F {-# UNPACK #-} !Frame
-         | P                !Status
+data Msg = I !Id3 | S !ByteString | F !Frame | P !Status
     deriving stock (Eq, Show)
 
 -- ID3 info
 data Id3 = Id3
-    { id3title  :: !ByteString
-    , id3artist :: !ByteString
-    , id3album  :: !ByteString
-    , id3str    :: !ByteString
+    { title  :: !ByteString
+    , artist :: !ByteString
+    , album  :: !ByteString
+    , str    :: !ByteString
     --  , year   :: Maybe ByteString
     --  , genre  :: Maybe ByteString }
     } deriving stock (Eq, Show)
 
 -- Frame decoding status updates (once per frame).
--- Current-frame and frames-remaining are integers; current-time and
--- time-remaining floating point numbers with two decimal places.
-data Frame = Frame {
-    currentFrame   :: !Int,
-    framesLeft     :: !Int,
-    currentTime    :: !(Fixed E2),
-    timeLeft       :: !(Fixed E2)
-    } deriving stock (Eq, Show)
+-- Current-time and time-remaining are numbers with two decimal places.
+data Frame = Frame { elapsed :: !(Fixed E2), left :: !(Fixed E2) }
+    deriving stock (Eq, Show)
 
 -- Stop/pause status.
 data Status = Stopped | Paused | Playing
     deriving stock (Eq, Show)
 
--- | Strip leading and trailing whitespace.
-trim :: ByteString -> ByteString
-trim = P.dropWhileEnd isSpace . P.dropSpace
-
-readPS :: ByteString -> Maybe Int
-readPS = fmap fst . P.readInt
-
 doP :: ByteString -> Maybe Msg
 doP s = do
     (p, _) <- P.uncons s
@@ -77,12 +66,10 @@
 -- Frame decoding status updates (once per frame).
 doF :: ByteString -> Maybe Msg
 doF s = do
-    f0 : f1 : f2 : f3 : _ <- pure $ P.split ' ' s
-    currentFrame <- readPS f0
-    framesLeft   <- readPS f1
-    currentTime  <- readMaybe $ P.unpack f2
-    timeLeft     <- max 0 <$> readMaybe (P.unpack f3)
-    pure $ F Frame { currentFrame, framesLeft, currentTime, timeLeft }
+    _ : _ : f2 : f3 : _ <- pure $ P.split ' ' s
+    elapsed <- readMaybe $ P.unpack f2
+    left    <- max 0 <$> readMaybe (P.unpack f3)
+    pure $ F Frame { elapsed, left }
 
 -- Info about mp3 file after loading.
 -- Breakdown from mpg123 README.remote (as numbers):
@@ -102,17 +89,16 @@
 doS s = do
     let fs = P.split ' ' s
     guard $ length fs >= 11
-    hz <- readPS $ fs !! 2
+    hz <- readIntM $ fs !! 2
     pure $ S $ mconcat [
-        "mpeg ", fs !! 0, " ", fs !! 10, "kb/s ",
-            P.pack $ show $ hz `div` 1000, "kHz"]
+        "mpeg ", fs !! 0, " ", fs !! 10, "kb/s ", showInt $ hz `div` 1000, "kHz"]
 
 -- Track info if ID fields are in the file, otherwise file name.
 doI :: ByteString -> Maybe Msg
 doI s = I <$> do
     ("ID3:", info) <- pure $ P.splitAt 4 s
     let id3 = parseId3 info
-    guard $ not $ P.null $ id3title id3 -- title sometimes empty
+    guard $ not $ P.null $ id3.title -- title sometimes empty
     pure id3
 
 -- Format: title (30), author (30), album (30), year (4), comment (30), genre
@@ -120,18 +106,11 @@
 parseId3 :: ByteString -> Id3
 parseId3 = toId . cut where
     cut f | P.null f = []
-          | True     = let (a, xs) = P.splitAt 30 f in normalise a : cut xs
+          | True     = let (a, xs) = P.splitAt 30 f
+                       in guessEncoding (trim a) : cut xs
     toId ls = Id3 (arg 0) (arg 1) (arg 2) $ mconcat $ intersperse " : "
         $ filter (not . P.null) [arg 1, arg 2, arg 0]
       where arg = fromMaybe "" . (ls !?)
-
--- | Strip spaces, and if seeming ISO-8859-1 convert to UTF-8
-normalise :: ByteString -> ByteString
-normalise raw =
-    let bs = trim raw
-    in if UTF8.replacement_char `elem` UTF8.toString bs
-        then UTF8.fromString $ P.unpack bs
-        else bs
 
 -- Parse line; on failure, return Just only if error to report.
 mpgParser :: ByteString -> Either (Maybe String) Msg
diff --git a/Elements.hs b/Elements.hs
new file mode 100644
--- /dev/null
+++ b/Elements.hs
@@ -0,0 +1,149 @@
+-- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
+-- Copyright (c) 2019-2026 Galen Huntington
+-- SPDX-License-Identifier: GPL-2.0-or-later
+
+module Elements where
+
+import Base
+import Decoder (Frame(..))
+import Keyboard (charToKey, historyKeys)
+import State
+import Text
+import Paths_hmp3_ng (version)
+
+import Data.ByteString.Char8 qualified as P
+import System.Clock
+import UI.HSCurses.Curses qualified as Curses
+
+
+package :: String
+package = "hmp3-ng"
+
+fullVersion :: String
+fullVersion  = package ++ " v" ++ showVersion version
+
+-- | Version info
+pVersion :: ByteString
+pVersion = P.pack fullVersion
+
+commonModalWidth :: Int -> Int
+commonModalWidth w = max (min w 3) $ round $ fromIntegral w * (0.8::Float)
+
+showClock :: Fixed E2 -> ByteString
+showClock t =
+    let m, si, sd :: Int
+        (m, s) = t `divMod'` 60
+        si     = floor s
+        sd     = floor (s*10) `mod` 10
+    in P.pack $ printf "%d:%02d.%d" m si sd
+
+-- | Human-friendly duration, with a flag to include seconds.
+showDuration :: Bool -> TimeSpec -> ByteString
+showDuration showSecs tm
+    | ms == 0 && showSecs
+              = go ""
+    | hs == 0 = go $ printf "%dm" m
+    | d == 0  = go $ printf "%dh%02dm" h m
+    | True    = go $ printf "%dd%02dh%02dm" d h m
+  where
+    go      = P.pack . ss
+    (ms, s) = sec tm `quotRem` 60
+    (hs, m) = ms `quotRem` 60
+    (d, h)  = hs `quotRem` 24
+    ss      =
+        if showSecs then (<> printf (if ms > 0 then "%02ds" else "%ds") s) else id
+
+-- | The time used and time left
+pTimes :: Int -> Maybe Frame -> ByteString
+pTimes w clock
+    | w - 4 < P.length elapsed = ""
+    | True                     =
+        mconcat $ ["  ", elapsed] ++ [gap <> "-" <> left | distance > 0]
+  where
+    elapsed  = showClock (maybe 0 (.elapsed) clock)
+    left     = maybe "?:??.?" (showClock . (.left)) clock
+    gap      = spaces distance
+    distance = w - 5 - P.length elapsed - P.length left
+
+-- | Progress out of total
+progress :: Int -> Maybe Frame -> Int
+progress width = maybe 0 \fr ->
+    let total    = curr + toRational fr.left - ε
+        curr     = toRational fr.elapsed
+        ε        = 1 / 200
+    in ceiling (curr * fromIntegral (width - 1) / total)
+
+data Fit = Fit { wide :: !Bool, padL :: !Int, padR :: !Int, ctake :: !Int }
+    deriving stock Show
+
+-- | Given a width and size of left, center, and right elements, determine
+-- whether left and right can fit, padding between, and amount of center to show
+fitLCR :: Int -> (Int, Int, Int) -> Fit
+fitLCR w (lsz, csz, rsz) = if
+    | gap >= 2   -> let gapl = 1 `max` ((side - lsz) `min` (gap - 1))
+                    in Fit True gapl (gap - gapl) csz
+    | w-2 >= csz -> Fit False side (sides - side) csz
+    | w > 1      -> Fit False 1 1 (w-2)
+    | True       -> Fit False w 0 0
+  where
+    sides = w - csz
+    side = sides `div` 2
+    gap  = sides - lsz - rsz
+
+layoutLCR :: Int -> (ByteString, String, ByteString) -> ByteString
+layoutLCR w (left, centerS, right) = mconcat [
+    if fit.wide then left else "",
+    spaces fit.padL,
+    u $ take fit.ctake centerS,
+    spaces fit.padR,
+    if fit.wide then right else ""
+    ]
+  where
+    fit = fitLCR w (P.length left, length centerS, P.length right)
+
+
+-- Modals
+
+-- screen width -> (modal width, list of lines)
+type ModalMaker = Int -> (Int, [ByteString])
+
+helpModal :: [KeysHelp] -> ModalMaker
+helpModal help swd = (wd, map showLine help) where
+    wd = commonModalWidth swd
+    showLine :: ([Char], ByteString) -> ByteString
+    showLine (cs, ps) = toWidth clen cmds <> ps where
+        clen = max 4 $ round $ fromIntegral wd * (0.2::Float)
+        cmds = P.unwords ("" : map pprIt cs)
+        pprIt c = case c of
+            '\n' -> "Enter"
+            '\f' -> "^L"
+            '\\' -> "\\"
+            ' '  -> "Space"
+            _ -> case charToKey c of
+                Curses.KeyUp        -> u"↑"
+                Curses.KeyDown      -> u"↓"
+                Curses.KeyPPage     -> "PgUp"
+                Curses.KeyNPage     -> "PgDn"
+                Curses.KeyLeft      -> u"←"
+                Curses.KeyRight     -> u"→"
+                Curses.KeyEnd       -> "End"
+                Curses.KeyHome      -> "Home"
+                Curses.KeyBackspace -> "Backspace"
+                _ -> u[c]
+
+histModal :: HistDisplay -> ModalMaker
+histModal []   _   = let s = "  No history  " in (P.length s, [s])
+histModal hist swd = do
+    let wd = commonModalWidth swd
+        mtlen = maximum $ map (displayWidth . fst) hist
+        tlen = min (mtlen + 1) $ wd `div` 3
+    (wd, [
+        let tstr = toMaxWidth tlen $ P.replicate (tlen - displayWidth time) ' ' <> time
+        in mconcat [" ", P.singleton c, " ", tstr, " ", song]
+        | (c, (time, (_, song))) <- zip (toList historyKeys ++ repeat ' ') hist ])
+
+exitModal :: ModalMaker
+exitModal swd = (wd, ["", padl <> "Exit (y)?", ""]) where
+    wd = commonModalWidth swd `min` 19
+    padl = P.replicate ((wd - 9) `div` 2) ' '
+
diff --git a/Keymap.hs b/Keymap.hs
--- a/Keymap.hs
+++ b/Keymap.hs
@@ -10,20 +10,24 @@
 -- transitions (entering search, popping up the song-history modal,
 -- confirming a quit) are just "return a different 'KeyMap'."
 --
-module Keymap (keyLoop, keyTable, unkey, charToKey) where
+module Keymap (keyLoop, keyTable, unkey, charToKey, dropLastUTF8) where
 
 import Base
 
 import Core
-import Config (package)
+import Elements (package)
 import Keyboard (unkey, charToKey, Key(..), historyKeys)
-import State (getsHS, modifyHS_, KeysHelp, Modal(..), HState(..))
-import Style (defaultSty, StringA(Fast))
+import State (getsHS, modifyHS_, KeysHelp, Modal(..), HState(..), SearchType(..), mpgRef, Mpg(..))
+import Style (plainSeg)
+import Text (dropLastUTF8)
 import UI qualified (getKey, resetui)
 
+import Control.Monad.Trans.Maybe
 import Data.ByteString.Char8 qualified as P
 import Data.ByteString.UTF8 qualified as UTF8
 import Data.Map.Strict qualified as M
+import System.Process (getPid)
+import System.Posix.Signals (signalProcess, sigINT)
 
 
 ------------------------------------------------------------------------
@@ -45,7 +49,7 @@
 -- Top-level normal mode
 
 mainMode :: KeyMap
-mainMode = KeyMap \c -> getsHS modal >>= \case
+mainMode = KeyMap \c -> getsHS (.modal) >>= \case
 
     Just ExitModal
         | c `elem` ['y', 'Y', '\^C'] -> shutdown Nothing $> undefined
@@ -58,30 +62,32 @@
     _ -> if
         | c `elem` ['/', '?', '\\', '|'] -> do
             toggleFocus
-            hist <- getsHS searchHist
+            hist <- getsHS (.searchHist)
             searchMode c $ Zipper "" hist []
-        | c `elem` ['q', '\^C'] ->
-            forcePause *> setsModal (const $ Just ExitModal) $> mainMode
-        | c `elem` ['H', ';'] ->
-            showHist $> mainMode
         | c >= '1' && c <= '9' ->
             jumpRel (fromIntegral (fromEnum c - 48) / 10) $> mainMode
         | True -> sequence_ (M.lookup c keyMap) $> mainMode
 
 
+-- Helpers
+
 historyKeyMap :: M.Map Char Int
 historyKeyMap = M.fromList $ zip (toList historyKeys) [0..]
 
+askExit :: IO ()
+askExit = setsModal $ const $ Just ExitModal
 
+controlC :: IO ()
+controlC = do
+    mpid <- runMaybeT do
+        mpg <- MaybeT $ readIORef mpgRef
+        MaybeT $ getPid mpg.mpgPH
+    maybe askExit (signalProcess sigINT) mpid
+
 ------------------------------------------------------------------------
 -- Search mode
 
--- | Zipper over the search-history list, with the currently edited
--- string in the focus.  'back' holds older entries we can step back
--- to (Up); 'front' holds entries we've stepped back from (Down).
-data Zipper = Zipper { cur :: !String, _back :: ![String], _front :: ![String] }
-
-searchMode :: Char -> Zipper -> IO KeyMap
+searchMode :: Char -> Zipper ByteString -> IO KeyMap
 searchMode stype = step where
     step z = renderSearch stype z $> KeyMap (`dispatch` z)
 
@@ -89,47 +95,32 @@
         | c `elem` ['\ESC', '\^C']
                            = clearMessage *> leave
         | c `elem` enter'  = commit z
-        | c `elem` delete' = step $ zipEdit dropLast z
+        | c `elem` delete' = step $ zipEdit dropLastUTF8 z
         | k == KeyUp       = step $ zipUp z
         | k == KeyDown     = step $ zipDown z
         | k == KeyDC       = histDelete z
         | c < ' ' || c > '\255'
                            = step z   -- ignore other special keys
-        | otherwise        = step $ zipEdit (++ [c]) z
+        | otherwise        = step $ zipEdit (`P.snoc` c) z
       where k = charToKey c
 
-    commit (Zipper []  _ _) = clearMessage *> leave
+    commit (Zipper ""  _ _) = clearMessage *> leave
     commit (Zipper pat _ _) = do
-        let jumpy = if stype `elem` ['/', '?']
-                    then jumpToMatchFile else jumpToMatchDir
-        jumpy (Just pat) (stype `elem` ['/', '\\'])
-        modifyHS_ \st -> st { searchHist = pat : filter (/= pat) (searchHist st) }
+        search (SearchType (stype `elem` ['/', '?']) (stype `elem` ['/', '\\'])) pat
+        modifyHS_ \st -> st { searchHist = pat : filter (/= pat) st.searchHist }
         leave
 
     histDelete z = do
         let z' = case z of
                 Zipper _ b (pv:rest) -> Zipper pv b rest
                 Zipper _ b _         -> Zipper "" b []
-        modifyHS_ \st -> st { searchHist = filter (/= cur z) (searchHist st) }
+        modifyHS_ \st -> st { searchHist = filter (/= z.cur) st.searchHist }
         step z'
 
     leave = toggleFocus $> mainMode
 
-renderSearch :: Char -> Zipper -> IO ()
-renderSearch prefix z = putMessage $ Fast (P.pack (prefix : cur z)) defaultSty
-
-dropLast :: [a] -> [a]
-dropLast [] = []
-dropLast xs = init xs
-
-zipEdit :: (String -> String) -> Zipper -> Zipper
-zipEdit f z = z { cur = f (cur z) }
-
-zipUp, zipDown :: Zipper -> Zipper
-zipUp   (Zipper c (nx:rest) f)  = Zipper nx rest (c:f)
-zipUp   z                       = z
-zipDown (Zipper c b (pv:rest))  = Zipper pv (c:b) rest
-zipDown z                       = z
+renderSearch :: Char -> Zipper ByteString -> IO ()
+renderSearch prefix z = putMessage [plainSeg $ prefix `P.cons` z.cur]
 
 enter', delete' :: [Char]
 enter'  = ['\n', '\r']
@@ -148,41 +139,55 @@
     , ("Jump to start of list",                   [unkey KeyHome,'0'],  jump 0)
     , ("Jump to end of list",                     [unkey KeyEnd,'G'],   jump maxBound)
     , ("Jump to 10%, 20%, 30%, etc., point",      ['1','2','3'],        placeholder)
-    , ("Seek left within song",                   [unkey KeyLeft],      seekLeft)
-    , ("Seek right within song",                  [unkey KeyRight],     seekRight)
+    , ("Seek 10 seconds; shift for one minute",   [unkey KeyLeft, unkey KeyRight], placeholder)
     , ("Toggle pause",                            [' '],                pause)
+    , ("Play under cursor",                       ['p'],                playCur)
     , ("Play from cursor",                        ['\n'],               playCursor)
     , ("Play previous track",                     ['K'],                playPrev)
     , ("Play next track",                         ['J'],                playNext)
     , ("Toggle the help screen",                  ['h'],                toggleHelp)
     , ("Jump to currently playing song",          ['t'],                jumpToPlaying)
     , ("Select and play next track",              ['d'],                playNext *> jumpToPlaying)
+    , ("Select random track",                     ['r'],                jumpRandom False)
+    , ("Select and play random track",            ['R'],                jumpRandom True)
     , ("Cycle through normal, random, loop, and single modes",
                                                   ['m'],                nextMode)
     , ("Refresh the display",                     ['\^L'],              UI.resetui)
-    , ("Repeat last regex search",                ['n'],                jumpToMatchFile Nothing True)
-    , ("Repeat last regex search backwards",      ['N'],                jumpToMatchFile Nothing False)
-    , ("Play",                                    ['p'],                playCur)
+    , ("Repeat last regex search",                ['n'],                repeatSearch True)
+    , ("Repeat last regex search backwards",      ['N'],                repeatSearch False)
     , ("Mark for deletion in .hmp3-delete",       ['D'],                blacklist)
-    , ("Load config file",                        ['l'],                loadConfig)
     , ("Restart song",                            [unkey KeyBackspace], seekStart)
-    , ("Toggle the song history",                 ['H', ';'],           placeholder)
+    , ("Toggle the song history",                 ['H', ';'],           showHist)
     , ("Search for file matching regex",          ['/'],                placeholder)
     , ("Search backwards for file",               ['?'],                placeholder)
     , ("Search for directory matching regex",     ['\\'],               placeholder)
     , ("Search backwards for directory",          ['|'],                placeholder)
-    , ("Quit " <> UTF8.fromString package,        ['q'],                placeholder)
+    , ("Change size of folder and file columns",  ['[', ']'],           placeholder)
+    , ("Load config file",                        ['l'],                loadConfig)
+    , ("Quit " <> UTF8.fromString package,        ['q'],                forcePause *> askExit)
     ]
   where placeholder = pure () -- handled separately
 
+-- | Not shown in help menu (or grouped there).
+quietKeys :: [(Char, IO ())]
+quietKeys =
+    [ (unkey KeyLeft,    seek (-10))
+    , (unkey KeyRight,   seek 10)
+    , (unkey KeySLeft,   seek (-60))
+    , (unkey KeySRight,  seek 60)
+    , ('[',              adjFolderCol (-1))
+    , (']',              adjFolderCol 1)
+    , ('\^C',            controlC)
+    ]
+
 -- Compiled dispatch table for normal-mode single-key commands.
 keyMap :: M.Map Char (IO ())
-keyMap = M.fromList [ (c, a) | (_, cs, a) <- keyTable, c <- cs ]
+keyMap = M.fromList $ [ (c, a) | (_, cs, a) <- keyTable, c <- cs ] ++ quietKeys
 
 keysHelp :: [KeysHelp]
 keysHelp = [ (keys, desc) | (desc, keys, _) <- keyTable ]
 
 toggleHelp :: IO ()
 toggleHelp = setsModal \st ->
-    if isNothing $ modal st then Just $ HelpModal keysHelp else Nothing
+    if isNothing st.modal then Just $ HelpModal keysHelp else Nothing
 
diff --git a/State.hs b/State.hs
--- a/State.hs
+++ b/State.hs
@@ -9,14 +9,15 @@
 
 import Base
 
-import Decoder                  (Status, Frame, Id3, Cmd, cmdToBS)
+import Decoder                  (Status, Frame, Id3, Cmd, cmdToBS, mp3Tool)
 import Playlist                 (FileArray, DirArray)
-import Style                    (StringA, UIStyle)
+import Style                    (Line, Segment(Seg), UIStyle(warnings))
 
 import Data.ByteString          (hPut)
+import GHC.Records
 import System.Clock             (TimeSpec(..))
 import System.IO                (hFlush)
-import System.Process           (ProcessHandle)
+import System.Process           (ProcessHandle, waitForProcess)
 import System.Random            (StdGen)
 
 
@@ -25,36 +26,37 @@
     -- These never change
     { music           :: !FileArray
     , folders         :: !DirArray
-    , size            :: !Int                  -- cache size of list
     , bootTime        :: !TimeSpec
     , configPath      :: !(Maybe FilePath)     -- style.conf override (CLI)
+    , histSize        :: !Int
     -- These can
     , current         :: !Int                  -- currently playing mp3
     , cursor          :: !Int                  -- mp3 under the cursor
     , clock           :: !(Maybe Frame)        -- current clock value
     , randomGen       :: !StdGen               -- random seed
-    , mpgPid          :: !(Maybe ProcessHandle) -- pid of decoder
     , spawns          :: !Integer              -- count of decoder spawns
-    , threads         :: ![ThreadId]           -- all our threads
     , id3             :: !(Maybe Id3)          -- maybe mp3 id3 info
     , info            :: !(Maybe ByteString)   -- mp3 info
     , status          :: !Status
-    , minibuffer      :: !StringA              -- contents of minibuffer
+    , minibuffer      :: !Line                 -- contents of minibuffer
     , modal           :: !(Maybe Modal)        -- modal visible
     , miniFocused     :: !Bool                 -- is the mini buffer focused?
+    , folderCol       :: !Float                -- portion of width for folders
     , mode            :: !Mode
     , uptime          :: !ByteString
-    , searchFw        :: !Bool                 -- active search direction
-    , searchHist      :: ![String]
-    , exiting         :: !Bool                 -- let mpg123 die?
+    , searchType      :: !SearchType
+    , searchHist      :: ![ByteString]
     , playHist        :: !(Seq (TimeSpec, Int))
-    , histSize        :: Int
-    , config          :: !UIStyle
+    , uiStyle         :: !UIStyle
     }
 
+instance HasField "size" HState Int where getField hs = length hs.music
+
 data Mode = Once | Loop | Random | Single
     deriving stock (Eq, Bounded, Enum, Show, Read)
 
+data SearchType = SearchType { isFiles :: !Bool, isForwards :: !Bool }
+
 -- Each is (timestamp-string, (song-index, song-name)).
 type HistDisplay = [(ByteString, (Int, ByteString))]
 
@@ -80,18 +82,47 @@
 ------------------------------------------------------------------------
 -- The decoder.
 
-data Mpg = Mpg { errh :: !Handle, writeh :: !Handle }
+-- | Decoder read handle (mpg123 stderr).
+mpgRead :: MVar Handle
+mpgRead = unsafePerformIO newEmptyMVar
+{-# NOINLINE mpgRead #-}
 
-mpg :: MVar Mpg
-mpg = unsafePerformIO newEmptyMVar
-{-# NOINLINE mpg #-}
+data Mpg = Mpg { mpgPH :: !ProcessHandle, writeHM :: !(MVar Handle) }
 
+-- | Decoder process and write handles.
+mpgRef :: IORef (Maybe Mpg)
+mpgRef = unsafePerformIO $ newIORef Nothing
+{-# NOINLINE mpgRef #-}
+
+overseeMpg :: (Handle, Handle, Handle, ProcessHandle) -> IO ()
+overseeMpg (writeH, _, errH, mpgPH) = do
+    putMVar mpgRead errH
+    writeHM <- newMVar writeH
+    writeIORef mpgRef $ Just Mpg { mpgPH, writeHM }
+    void $ try @SomeException $ waitForProcess mpgPH
+    writeIORef mpgRef Nothing
+    void $ takeMVar mpgRead
+
+-- | Returns whether succeeded.
+sendMpg' :: Cmd -> IO Bool
+sendMpg' c = do
+    mpg <- readIORef mpgRef
+    case mpg of
+        Just Mpg { writeHM } -> do
+            h <- readMVar writeHM
+            fmap isRight $ try @SomeException $
+                hPut h (cmdToBS c) *> hPut h "\n" *> hFlush h
+        _ -> pure False
+
+-- | Runs above and posts warning on failure.
 sendMpg :: Cmd -> IO ()
-sendMpg c = withMVar mpg $ (. writeh) \h ->
-    hPut h (cmdToBS c) *> hPut h "\n" *> hFlush h
+sendMpg c = do
+    ok <- sendMpg' c
+    when (not ok) $ modifyHS_ \st -> st { minibuffer =
+        [Seg st.uiStyle.warnings (mp3Tool <> " process not running")] }
 
 ------------------------------------------------------------------------
--- state accessor functions
+-- State accessor functions.
 
 -- | Access a component of the state with a projection function
 getsHS :: (HState -> a) -> IO a
diff --git a/Style.hs b/Style.hs
--- a/Style.hs
+++ b/Style.hs
@@ -2,9 +2,7 @@
 -- Copyright (c) 2019-2022, 2026 Galen Huntington
 -- SPDX-License-Identifier: GPL-2.0-or-later
 
---
 -- | Color manipulation
---
 
 module Style where
 
@@ -47,18 +45,16 @@
 data Style = Style !Color !Color
     deriving stock (Eq,Ord)
 
--- | A list of styled UTF-8 ByteString segments making up one line.
--- 'Fast' is the single-segment fast path; 'FancyS' is a multi-segment line.
-data StringA
-    = Fast   {-# UNPACK #-} !ByteString {-# UNPACK #-} !Style
-    | FancyS ![(ByteString, Style)]
+-- | A styled UTF-8 ByteString segment.
+data Segment = Seg !Style {-# UNPACK #-} !ByteString
 
+-- | A line of segments.
+type Line = [Segment]
+
 ------------------------------------------------------------------------
---
 -- | Named colors for the config file and the built-in styles.  The
 -- \"dark\" name of each pair is the normal-intensity hue; the plain name
 -- is its bright variant (so @red@ is bright, @darkred@ is normal).
---
 stringToColor :: String -> Maybe Color
 stringToColor s = case map toLower s of
     "black"         -> Just $ Color Normal Black
@@ -82,44 +78,33 @@
     _               -> Nothing
 
 ------------------------------------------------------------------------
---
 -- | Set some colours, perform an action, and then reset the colours
---
 withStyle :: Style -> IO () -> IO ()
 withStyle sty fn = uiAttr sty >>= setAttribute >> fn >> reset
 {-# INLINE withStyle #-}
 
---
 -- | manipulate the current attributes of the standard screen
 -- Only set attr if it's different to the current one?
---
 setAttribute :: (Curses.Attr, Curses.Pair) -> IO ()
 setAttribute = uncurry Curses.attrSet
-{-# INLINE setAttribute #-}
 
---
 -- | Reset the screen to normal values
---
 reset :: IO ()
 reset = setAttribute (Curses.attr0, Curses.Pair 0)
-{-# INLINE reset #-}
 
---
 -- | And turn on the colours
---
 initcolours :: UIStyle -> IO ()
 initcolours sty = do
-    let ls  = [modals sty, warnings sty, window sty,
-               selected sty, titlebar sty, progress sty,
-               blockcursor sty, cursors sty, combined sty ]
-        (Style fg bg) = progress sty    -- bonus style
+    let ls  = [sty.modals, sty.warnings, sty.window,
+               sty.selected, sty.titlebar, sty.progress,
+               sty.blockcursor, sty.cursors, sty.combined ]
+        Style fg bg = sty.progress    -- bonus style
     pairs <- initUiColors (ls ++ [Style bg bg, Style fg fg])
     writeIORef pairMap pairs
     -- set the background
-    uiAttr (window sty) >>= \(_,p) -> Curses.bkgrndSet nullA p
+    uiAttr sty.window >>= \(_,p) -> Curses.bkgrndSet nullA p
 
 ------------------------------------------------------------------------
---
 -- | Set up the ui attributes, given a ui style record
 --
 -- Returns an association list of pairs for foreground and bg colors,
@@ -138,24 +123,20 @@
         pure (sty, (a `Curses.attrPlus` b, Curses.Pair p))
 
 ------------------------------------------------------------------------
---
 -- | Getting from nice abstract colours to ncurses-settable values
 
 -- 20% of allocss occur here! But there's only 3 or 4 colours :/
 -- Every call to uiAttr
---
 uiAttr :: Style -> IO (Curses.Attr, Curses.Pair)
 uiAttr sty = do
     m <- readIORef pairMap
     pure $ lookupPair m sty
-{-# INLINE uiAttr #-}
 
 -- | Given a curses color pair, find the Curses.Pair (i.e. the pair
 -- curses thinks these colors map to) from the state
 lookupPair :: PairMap -> Style -> (Curses.Attr, Curses.Pair)
 lookupPair m s =
     fromMaybe (Curses.attr0, Curses.Pair 0) (M.lookup s m)
-{-# INLINE lookupPair #-}
 
 -- | Keep a map of nice style defs to underlying curses pairs, created at init time
 type PairMap = M.Map Style (Curses.Attr, Curses.Pair)
@@ -166,22 +147,17 @@
 {-# NOINLINE pairMap #-}
 
 ------------------------------------------------------------------------
---
 -- Basic (ncurses) colours.
---
+
 defaultColor :: Curses.Color
 defaultColor = fromJust $ Curses.color "default"
 
---
 -- Combine attribute with another attribute
---
 setBoldA, setReverseA ::  Curses.Attr -> Curses.Attr
 setBoldA     = flip Curses.setBold    True
 setReverseA  = flip Curses.setReverse True
 
---
 -- | Some attribute constants
---
 boldA, nullA, reverseA :: Curses.Attr
 nullA       = Curses.attr0
 boldA       = setBoldA      nullA
@@ -194,7 +170,6 @@
 -- | Map an abstract 'Style' to its ncurses foreground/background pair.
 style2curses :: Style -> (CColor, CColor)
 style2curses (Style fg bg) = (fgCursCol fg, bgCursCol bg)
-{-# INLINE style2curses #-}
 
 -- | The ncurses color for each ANSI hue.
 hueColor :: Hue -> Curses.Color
@@ -222,8 +197,10 @@
 style :: String -> String -> Style
 style a b = let f = fromJust . stringToColor in Style (f a) (f b)
 
+plainSeg :: ByteString -> Segment
+plainSeg = Seg defaultSty
+
 ------------------------------------------------------------------------
---
 -- Support for runtime configuration
 -- We choose a simple strategy, read/showable record types, with strings
 -- to represent colors
@@ -244,23 +221,48 @@
        , hmp3_progress    :: (String,String)
      } deriving stock (Show,Read)
 
---
 -- | Read style.conf, and construct a UIStyle from it, to insert into
---
 buildStyle :: Config -> UIStyle
 buildStyle bs = UIStyle {
-         window      = f $ hmp3_window      bs
-       , modals      = f $ hmp3_modals      bs
-       , titlebar    = f $ hmp3_titlebar    bs
-       , selected    = f $ hmp3_selected    bs
-       , cursors     = f $ hmp3_cursors     bs
-       , combined    = f $ hmp3_combined    bs
-       , warnings    = f $ hmp3_warnings    bs
-       , blockcursor = f $ hmp3_blockcursor bs
-       , progress    = f $ hmp3_progress    bs
+         window      = f bs.hmp3_window
+       , modals      = f bs.hmp3_modals
+       , titlebar    = f bs.hmp3_titlebar
+       , selected    = f bs.hmp3_selected
+       , cursors     = f bs.hmp3_cursors
+       , combined    = f bs.hmp3_combined
+       , warnings    = f bs.hmp3_warnings
+       , blockcursor = f bs.hmp3_blockcursor
+       , progress    = f bs.hmp3_progress
     }
-
     where 
         f (x,y) = Style (g x) (g y)
         g x     = fromMaybe Default $ stringToColor x
+
+-- Built-in styles
+
+defaultStyle :: UIStyle
+defaultStyle  = UIStyle
+    { window     = style "default"      "default"
+    , titlebar   = style "brightwhite"  "green"
+    , selected   = style "blue"         "default"
+    , cursors    = style "black"        "cyan"
+    , combined   = style "brightwhite"  "cyan"
+    , warnings   = style "red"          "default"
+    , modals     = style "black"        "white"
+    , blockcursor= style "black"        "red"
+    , progress   = style "cyan"         "white"
+    }
+
+monoStyle :: UIStyle
+monoStyle = UIStyle
+    { window      = style "default"     "default"
+    , titlebar    = style "reverse"     "reverse"
+    , selected    = style "brightwhite" "default"
+    , cursors     = style "reverse"     "reverse"
+    , combined    = style "reverse"     "reverse"
+    , warnings    = style "reverse"     "reverse"
+    , modals      = style "reverse"     "reverse"
+    , blockcursor = style "reverse"     "reverse"
+    , progress    = style "reverse"     "reverse"
+    }
 
diff --git a/Text.hs b/Text.hs
new file mode 100644
--- /dev/null
+++ b/Text.hs
@@ -0,0 +1,89 @@
+-- Copyright (c) 2019-2026 Galen Huntington
+-- SPDX-License-Identifier: GPL-2.0-or-later
+
+-- For various reasons, ByteString is the lingua franca for this app.
+-- This module provides basic text string functions.
+
+module Text (
+    u, matches,
+    trim, spaces, guessEncoding, dropLastUTF8,
+    readIntM, showInt,
+    displayWidth, toMaxWidth, toWidth
+) where
+
+import Base
+
+import Data.ByteString.Char8 qualified as P
+import Data.ByteString.UTF8 qualified as UTF8
+import Text.Regex.Posix (match, makeRegexOptsM, compIgnoreCase, compExtended)
+
+import Foreign.C.Types (CWchar(..), CInt(..))
+
+
+-- | Write u-strings like it's Python 2.
+u :: String -> ByteString
+u = UTF8.fromString
+
+-- | Strip leading and trailing whitespace.
+trim :: ByteString -> ByteString
+trim = P.dropWhileEnd isSpace . P.dropSpace
+
+spaces :: Int -> ByteString
+spaces = flip P.replicate ' '
+
+-- | Swappable API for searching
+matches :: ByteString -> Maybe (ByteString -> Bool)
+matches s = match <$> makeRegexOptsM (compIgnoreCase + compExtended) 0 s
+
+readIntM :: ByteString -> Maybe Int
+readIntM = fmap fst . P.readInt
+
+showInt :: Int -> ByteString
+showInt = P.pack . show
+
+-- | If seeming ISO-8859-1, convert to UTF-8.
+guessEncoding :: ByteString -> ByteString
+guessEncoding bs =
+    if UTF8.replacement_char `elem` UTF8.toString bs
+        then UTF8.fromString $ P.unpack bs
+        else bs
+
+-- | Drop last UTF-8 codepoint.
+dropLastUTF8 :: ByteString -> ByteString
+dropLastUTF8 = P.dropEnd 1 . P.dropWhileEnd isCB
+    where isCB b = b >= '\128' && b < '\192'
+
+
+-- Width-aware operations on UTF-8 'ByteString's, using libc 'wcwidth'.
+-- A UTF-8 runtime locale is presumed; counts may differ otherwise.
+
+-- | Sum of the column widths of every codepoint in a UTF-8 'ByteString'.
+displayWidth :: ByteString -> Int
+displayWidth = UTF8.foldl (\acc c -> acc + charWidth c) 0
+
+-- | These functions truncate with ellipses if needed to get width ≤'w'.
+-- 'toWidth' adds padding as needed so the width is exactly 'w'.
+toMaxWidth, toWidth :: Int -> ByteString -> ByteString
+toMaxWidth = sizer False
+toWidth = sizer True
+
+sizer :: Bool -> Int -> ByteString -> ByteString
+sizer pad w bs
+    | dw <= w = if pad then bs <> P.replicate (w-dw) ' ' else bs
+    | True    = walk 0 bs
+  where
+    dw = displayWidth bs
+    walk !l rest
+        | l' >= w = P.take (P.length bs - P.length rest) bs
+                        <> mconcat (replicate (w-l) $ UTF8.fromString "…")
+        | True    = walk l' rest'
+      where
+        (c, rest') = fromJust $ UTF8.uncons rest -- can't be at end since dw>w
+        l'         = l + charWidth c
+
+charWidth :: Char -> Int
+charWidth = fromIntegral . wcwidth . toEnum . fromEnum
+
+foreign import ccall safe
+    wcwidth :: CWchar -> CInt
+
diff --git a/UI.hs b/UI.hs
--- a/UI.hs
+++ b/UI.hs
@@ -5,29 +5,27 @@
 -- Derived from: riot/UI.hs Copyright (c) Tuomo Valkonen 2004.
 -- Released under the same license.
 
---
 -- | This module defines a user interface implemented using ncurses. 
---
---
 
 module UI (
     runDraw,
     -- * Construction, destruction
     start, end, screenSize, refresh, refreshClock, resetui,
     -- * Input
-    getKey
+    getKey,
+    -- * Tool
+    u,
   ) where
 
 import Base
-
+import Elements as El
 import Style
 import Playlist                 (File(fdir, fbase), Dir(dname))
 import State
 import Decoder
-import Config
-import Width                    (displayWidth, toMaxWidth, toWidth)
+import Text                     (u, displayWidth, toMaxWidth, toWidth, spaces, showInt)
 import UI.HSCurses.Curses qualified as Curses
-import Keyboard                 (unkey, charToKey, historyKeys)
+import Keyboard                 (unkey)
 
 import Data.Array               ((!), bounds, Array)
 import Data.Array.Base          (unsafeAt)
@@ -41,14 +39,8 @@
 
 import Data.ByteString.Char8 qualified as P
 import Data.ByteString.Unsafe qualified as P
-import Data.ByteString.UTF8 qualified as UTF8
 
 
--- Write u-strings like it's Python 2.
-u :: String -> ByteString
-u = UTF8.fromString
-
-
 newtype Draw = Draw (IO ())
     deriving newtype (Semigroup, Monoid)
 
@@ -71,7 +63,7 @@
         _        -> pure () -- handled elsewhere
 
     colorify <- Curses.hasColors
-    let sty = if colorify then defaultStyle else bwStyle
+    let sty = if colorify then defaultStyle else monoStyle
 
     initcolours sty
     Curses.keypad Curses.stdScr True    -- grab the keyboard
@@ -98,10 +90,8 @@
 screenSize :: IO (Int, Int)
 screenSize = Curses.scrSize
 
---
 -- | Rewrite of Curses.getCh to avoid looping on terminal crash
 -- | (also no unget support since I don't need it)
---
 getCh :: IO Curses.Key
 getCh = do
   threadWaitRead 0
@@ -113,10 +103,8 @@
         exitFailure
     k -> pure $ Curses.decodeKey k
 
---
 -- | Read a key. UIs need to define a method for getting events.
 -- We only need to refresh if we don't have no SIGWINCH support.
---
 getKey :: IO Char
 getKey = do
     k <- getCh
@@ -129,9 +117,8 @@
 
 -- | Resize the window
 -- From "Writing Programs with NCURSES", by Eric S. Raymond and Zeyd M. Ben-Halim
---
 resizeui :: Draw
-resizeui = Draw $ void $ do
+resizeui = Draw do
     Curses.endWin
     Curses.resetParams
     do
@@ -143,7 +130,7 @@
         -- Curses.meta stdScr True -- not in module
         -- not sure about intrFlush, raw - set in hscurses
     Curses.refresh
-    Curses.scrSize
+    void Curses.scrSize
 
 refresh :: IO ()
 refresh = runDraw $ redraw <> Draw Curses.refresh
@@ -153,35 +140,16 @@
 
 ------------------------------------------------------------------------
 
--- (prefix some with underscore to avoid unused warnings)
-data Pos  = Pos  { posY, _posX :: !Int }
-data Size = Size { _sizeH, sizeW :: !Int }
-
--- | Renderable widgets are functions @DrawData -> ...@.
-data DrawData = DD {
-    drawSize  :: Size,
-    drawPos   :: Pos,
-    drawState :: HState,
-    drawFrame :: Maybe Frame
-    }
-
--- screen width -> (modal width, list of lines)
-type ModalMaker = Int -> (Int, [ByteString])
-
-------------------------------------------------------------------------
-
--- | The three lines of the play info widget.
-playScreen :: DrawData -> [StringA]
-playScreen dd = [pPlaying dd, progressBar dd, pTimes dd]
+data DrawData = DD { drawWidth :: !Int, drawState :: !HState }
 
 ------------------------------------------------------------------------
 
 -- | Info about the current track
-pPlaying :: DrawData -> StringA
-pPlaying dd = flip Fast defaultSty $ "  " <> mconcat line where
-    x = sizeW $ drawSize dd
+pPlaying :: DrawData -> Line
+pPlaying dd = pure $ plainSeg $ "  " <> mconcat line where
+    x = dd.drawWidth
     a = pId3 dd
-    b = pInfo dd
+    b = fromMaybe "" dd.drawState.info  -- mp3 info
     line | gap >= 0 = a : spaces gap : right
          | True     = toMaxWidth lim a : right
         where lim = x - 5 - (if showId3 then P.length b else -1)
@@ -191,215 +159,89 @@
 
 -- | Id3 info
 pId3 :: DrawData -> ByteString
-pId3 DD{drawState=st} = case id3 st of
-    Just i  -> id3str i
-    Nothing -> fbase $ music st ! current st
-
--- | mp3 information
-pInfo :: DrawData -> ByteString
-pInfo DD{drawState=st} = fromMaybe "" $ info st
-
-commonModalWidth :: Int -> Int
-commonModalWidth w = max (min w 3) $ round $ fromIntegral w * (0.8::Float)
-
-------------------------------------------------------------------------
-
-helpModal :: [KeysHelp] -> ModalMaker
-helpModal help swd = (wd, map showLine help) where
-    wd = commonModalWidth swd
-    showLine :: ([Char], ByteString) -> ByteString
-    showLine (cs, ps) = toWidth clen cmds <> ps where
-        clen = max 4 $ round $ fromIntegral wd * (0.2::Float)
-        cmds = P.unwords ("" : map pprIt cs)
-        pprIt c = case c of
-            '\n' -> "Enter"
-            '\f' -> "^L"
-            '\\' -> "\\"
-            ' '  -> "Space"
-            _ -> case charToKey c of
-                Curses.KeyUp        -> u"↑"
-                Curses.KeyDown      -> u"↓"
-                Curses.KeyPPage     -> "PgUp"
-                Curses.KeyNPage     -> "PgDn"
-                Curses.KeyLeft      -> u"←"
-                Curses.KeyRight     -> u"→"
-                Curses.KeyEnd       -> "End"
-                Curses.KeyHome      -> "Home"
-                Curses.KeyBackspace -> "Backspace"
-                _ -> u[c]
-
-------------------------------------------------------------------------
-
-histModal :: HistDisplay -> ModalMaker
-histModal []   _   = let s = "  No history  " in (P.length s, [s])
-histModal hist swd = do
-    let wd = commonModalWidth swd
-        mtlen = maximum $ map (displayWidth . fst) hist
-        tlen = min (mtlen + 1) $ wd `div` 3
-    (wd, [
-        let tstr = toMaxWidth tlen $ P.replicate (tlen - displayWidth time) ' ' <> time
-        in mconcat [" ", P.singleton c, " ", tstr, " ", song]
-        | (c, (time, (_, song))) <- zip (toList historyKeys ++ repeat ' ') hist ])
-
-------------------------------------------------------------------------
-
-exitModal :: ModalMaker
-exitModal swd = (wd, ["", padl <> "Exit (y)?", ""]) where
-    wd = commonModalWidth swd `min` 19
-    padl = P.replicate ((wd - 9) `div` 2) ' '
+pId3 DD{drawState=st} = maybe (st.music ! st.current).fbase (.str) st.id3
 
 ------------------------------------------------------------------------
 
-showClock :: Fixed E2 -> ByteString
-showClock t =
-    let m, si, sd :: Int
-        (m, s) = t `divMod'` 60
-        si     = floor s
-        sd     = floor (s*10) `mod` 10
-    in P.pack $ printf "%d:%02d.%d" m si sd
-
--- | The time used and time left
-pTimes :: DrawData -> StringA
-pTimes DD { drawFrame=Just Frame {..}, drawSize=Size{sizeW=w} } =
-    flip Fast defaultSty $ if w - 4 < P.length elapsed
-        then ""
-        else mconcat $ ["  ", elapsed] ++ [gap <> "-" <> remaining | distance > 0]
+-- | Show progress bar.
+progressBar :: DrawData -> Line
+progressBar (DD w st) = [
+    plainSeg "  ", Seg (Style fg fg) (spaces x), Seg sty (spaces (w'-x)) ]
   where
-    elapsed   = showClock currentTime
-    remaining = showClock timeLeft
-    gap       = spaces distance
-    distance  = w - 5 - P.length elapsed - P.length remaining
-pTimes _ = Fast "" defaultSty
-
-------------------------------------------------------------------------
+    w' = w - 4
+    x = El.progress w' st.clock
+    sty@(Style fg _) = st.uiStyle.progress
 
--- | A progress bar
-progressBar :: DrawData -> StringA
-progressBar dd@DD{drawSize=Size{sizeW=w}, drawState=st} = case drawFrame dd of
-    Nothing -> FancyS [("  ", defaultSty), (spaces (w-4), bgs)]
-      where
-        Style _ bg = progress (config st)
-        bgs        = Style bg bg
-    Just Frame {..} -> FancyS
-        [ ("  ", defaultSty)
-        , (spaces distance, fgs)
-        , (spaces (width - distance), bgs) ]
-      where
-        width    = w - 4
-        total    = curr + left
-        distance = round ((curr / total) * fromIntegral width)
-        curr     = realToFrac currentTime :: Float
-        left     = realToFrac timeLeft
-        Style fg bg = progress (config st)
-        bgs         = Style bg bg
-        fgs         = Style fg fg
+-- | Two lines showing clock.
+clockLines :: DrawData -> [Line]
+clockLines dd@(DD w st) = [progressBar dd, [plainSeg (El.pTimes w st.clock)]]
 
 ------------------------------------------------------------------------
 
--- | Version info
-pVersion :: ByteString
-pVersion = P.pack versinfo
-
--- | Uptime
-pTime :: DrawData -> ByteString
-pTime = uptime . drawState
-
 -- | Play state
 pState :: DrawData -> String
-pState dd = case status (drawState dd) of
+pState dd = case dd.drawState.status of
     Stopped -> "◼"
     Paused  -> "⏸"
     Playing -> "▶"
 
 -- | Play mode
 pMode :: DrawData -> String
-pMode dd = take 4 $ map toLower $ show $ mode $ drawState dd
+pMode dd = take 4 $ map toLower $ show dd.drawState.mode
 
 ------------------------------------------------------------------------
 
 -- | "x/n dirs y/m files" cursor position read-out.
 playInfo :: DrawData -> ByteString
-playInfo dd = mconcat
+playInfo DD{drawState=st} = mconcat
     [ spaces (P.length numd - P.length curd)
     , curd, "/", numd, " dirs"
     , spaces (1 + P.length numf - P.length curf)
     , curf, "/", numf, " files"
     ]
   where
-    st   = drawState dd
-    tobs = P.pack . show
-    curf  = tobs $ 1 + cursor st
-    numf  = tobs $ size st
-    mydir = fdir $ music st ! cursor st
-    curd  = tobs $ 1 + mydir
-    numd  = tobs $ length $ folders st
+    curf  = showInt $ st.cursor + 1
+    numf  = showInt $ st.size
+    curd  = showInt $ (st.music ! st.cursor).fdir + 1
+    numd  = showInt $ length $ st.folders
 
 -- | The top title bar: cursor position + play indicator + uptime + version.
-playTitle :: DrawData -> StringA
-playTitle dd =
-    flip Fast hl $ mconcat if gap >= 2
-        then [" ", inf, spaces gapl, indic, spaces gapr, time, " ", ver, " "]
-        else let gap' = x - indicl; gapl' = gap' `div` 2
-             in if gap' >= 2
-                then [spaces gapl', indic, spaces $ gap' - gapl']
-                else [" ", P.take (x-2) indic, " "]
+playTitle :: DrawData -> Line
+playTitle dd@DD{drawWidth=w, drawState=st} =
+    [Seg st.uiStyle.titlebar $ El.layoutLCR w (left, centerS, right)]
   where
-    inf     = playInfo dd
-    time    = pTime dd
-    indic   = u $ pState dd ++ ' ' : pMode dd
-    ver     = pVersion
+    left    = " " <> playInfo dd
+    centerS = pState dd ++ ' ' : pMode dd  -- always 6 chars
+    right   = st.uptime <> " " <> El.pVersion <> " "
 
-    x       = sizeW $ drawSize dd
-    lsize   = 1 + P.length inf
-    rsize   = 2 + P.length time + P.length ver
-    side    = (x - indicl) `div` 2
-    gap     = x - indicl - lsize - rsize
-    gapl    = 1 `max` ((side - lsize) `min` (gap - 1))
-    gapr    = gap - gapl
-    indicl  = 6 -- length indic
-    hl      = titlebar . config $ drawState dd
+-- | The scrolling playlist (visible tracks).
+playList :: Int -> DrawData -> [Line]
+playList buflen _ | buflen <= 0 = []  -- extra defense besides laziness
+playList buflen DD{ drawWidth=w, drawState=st } =
+    list ++ replicate (buflen - length list) []
 
--- | The scrolling playlist (title + visible tracks + minibuffer).
-playList :: DrawData -> [StringA]
-playList dd@DD{ drawSize=Size y x, drawPos=Pos{posY=o}, drawState=st } =
-    playTitle dd
-    : list
-    ++ replicate (height - length list - 2) (Fast P.empty defaultSty)
-    ++ [minibuffer st]
   where
-    songs  = music st
-    this   = current st
-    curr   = cursor  st
-    height = y - o
-
     -- number of screens down, and then offset
-    buflen = height - 2
-    (screens, select) = quotRem curr buflen -- keep cursor in screen
-
-    playing = let top = screens * buflen
-                  bot = (screens + 1) * buflen
-              in if this >= top && this < bot
-                    then this - top -- playing song is visible
-                    else (-1)
+    (screens, select) = st.cursor `quotRem` buflen -- keep cursor in screen
+    playing = st.current - screens * buflen -- invisible if out of bounds
 
     -- visible slice of the playlist
-    visible = slice off (off + buflen) songs
+    visible = slice off (off + buflen - 1) st.music
         where off = screens * buflen
 
     visible' :: [(Maybe Int, ByteString)]
     visible' = loop (-1) visible where
         loop _ []     = []
         loop n (v:vs) =
-            let r = if fdir v > n then Just (fdir v) else Nothing
-            in (r, toMaxWidth (x - indent - 1) $ fbase v)
-                    : loop (fdir v) vs
+            let r = if v.fdir > n then Just v.fdir else Nothing
+            in (r, toMaxWidth (w - indent - 1) v.fbase) : loop v.fdir vs
 
     list   = [ drawIt . color $ n | n <- zip visible' [0..] ]
 
-    indent = (round $ (0.334 :: Float) * fromIntegral x) :: Int
+    indent = round $ st.folderCol * fromIntegral (w - 1) :: Int
 
-    (sty1, sty2, sty3) = (selected cs, cursors cs, combined cs)
-        where cs = config st
+    (sty1, sty2, sty3) = (cs.selected, cs.cursors, cs.combined)
+        where cs = st.uiStyle
 
     color :: ((Maybe Int, ByteString), Int)
                 -> (Maybe Int, (Style, [ByteString]))
@@ -409,140 +251,98 @@
         (_   , True) -> f sty1
         _            -> (defaultSty, [s])
       where
-        f sty = (sty, [s, spaces (x - indent - 1 - displayWidth s)])
+        f sty = (sty, [s, spaces (w - indent - 1 - displayWidth s)])
 
-    drawIt :: (Maybe Int, (Style, [ByteString])) -> StringA
+    drawIt :: (Maybe Int, (Style, [ByteString])) -> Line
     drawIt (Nothing, (sty, v)) =
-        FancyS $ map (, sty) $ spaces (1 + indent) : v
-    drawIt (Just i, (sty, v)) = FancyS
-        $ (d, sty')
-        : (spaces (indent + 1 - displayWidth d), sty')
-        : map (, sty) v
+        map (Seg sty) $ spaces (1 + indent) : v
+    drawIt (Just i, (sty, v)) = Seg sty' d
+        : Seg sty' (spaces (indent + 1 - displayWidth d))
+        : map (Seg sty) v
       where
         sty' = if sty == sty2 || sty == sty3 then sty2 else sty1
-        d = toMaxWidth (indent - 1) $ takeFileName $ dname $ folders st ! i
-
-------------------------------------------------------------------------
-
-spaces :: Int -> ByteString
-spaces = flip P.replicate ' '
+        d = toMaxWidth (indent - 1) $ takeFileName (st.folders ! i).dname
 
 ------------------------------------------------------------------------
--- | Now write out just the clock line
+-- | Write out only the clock lines.
 redrawJustClock :: Draw
 redrawJustClock = Draw $ discardErrors do
-    st     <- getsHS id
+    st <- getsHS id
     (h, w) <- screenSize
-    let dd = DD (Size h w) undefined st (clock st)
-    Curses.wMove Curses.stdScr 1 0   -- hardcoded!
-    drawLine $ progressBar dd
-    Curses.wMove Curses.stdScr 2 0   -- hardcoded!
-    drawLine $ pTimes dd
+    drawFullLines (h-1) 1 $ clockLines $ DD w st
 
 ------------------------------------------------------------------------
 -- | General modal renderer.
-renderModal :: HState -> Size -> ModalMaker -> IO ()
-renderModal st (Size h w) mkr = do
+renderModal :: HState -> (Int, Int) -> ModalMaker -> IO ()
+renderModal st (h, w) mkr = do
     let (mw, modal') = mkr w
         hoffset = max 0 $ (w - mw) `div` 2
         vislines = (h - 5) `min` length modal'
         voffset = ((h - vislines) `div` 2) `max` 4
-        sty = modals $ config st
-    Curses.wMove Curses.stdScr voffset hoffset
-    for_ (take vislines modal') \t -> do
-        drawLine $ Fast (toWidth mw t) sty
-        (y', _) <- Curses.getYX Curses.stdScr
-        Curses.wMove Curses.stdScr (y'+1) hoffset
+    for_ (zip [voffset..] $ take vislines modal') \ (y, t) -> do
+        Curses.wMove Curses.stdScr y hoffset
+        drawSegment $ Seg st.uiStyle.modals (toWidth mw t)
 
 -- | Choose modal to render based on state.
-renderModals :: HState -> Size -> IO ()
+renderModals :: HState -> (Int, Int) -> IO ()
 renderModals st sz =
-    whenJust (modal st) $ renderModal st sz . \case
-        HelpModal h -> helpModal h
-        HistModal h -> histModal h
-        ExitModal   -> exitModal
+    whenJust st.modal $ renderModal st sz . \case
+        HelpModal h -> El.helpModal h
+        HistModal h -> El.histModal h
+        ExitModal   -> El.exitModal
 
 ------------------------------------------------------------------------
 -- | Draw the screen
+-- Errors can maybe be thrown if screen gets resized mid-render.
 redraw :: Draw
-redraw = Draw $ discardErrors {- TODO what errors are discarded? -} do
-    st <- getsHS id    -- another refresh could be triggered?
-    (h, w) <- screenSize
-    let sz     = Size h w
-        screen = playScreen (DD sz (Pos 0 0) st (clock st))
-        a      = screen ++ playList (DD sz (Pos (length screen) 0) st (clock st))
-
+redraw = Draw $ discardErrors do
+    st <- getsHS id
+    sz@(h, w) <- screenSize
     setXterm st
-
-    gotoTop
-    for_ (take (h-1) (init a)) \t -> do
-        drawLine t
-        (y, x) <- Curses.getYX Curses.stdScr
-        fillLine
-        maybeLineDown t h y x
+    drawFullLines (h-1) 0 $ let dd = DD w st in
+        pPlaying dd : clockLines dd ++ playTitle dd : playList (h-5) dd
     renderModals st sz
-
     -- minibuffer
     Curses.wMove Curses.stdScr (h-1) 0
+    drawLine st.minibuffer
+    when st.miniFocused do -- a fake cursor
+        drawSegment $ Seg st.uiStyle.blockcursor " "
     fillLine
-    Curses.wMove Curses.stdScr (h-1) 0
-    drawLine (last a)
-    when (miniFocused st) do -- a fake cursor
-        drawLine (Fast (spaces 1) (blockcursor . config $ st ))
-        -- XXX is this TODO from 2005 still relevant?
-        -- todo rendering bug here when deleting backwards in minibuffer
 
+-- | Render whole lines without going below limit.
+drawFullLines :: Int -> Int -> [Line] -> IO ()
+drawFullLines limit y ls =
+    for_ (zip [y .. limit-1] ls) \ (y', t) -> do
+        Curses.wMove Curses.stdScr y' 0
+        drawLine t *> fillLine
+
 ------------------------------------------------------------------------
 -- | Draw a coloured (or not) string to the screen
-drawLine :: StringA -> IO ()
-drawLine (Fast ps sty) = drawSegment ps sty
-drawLine (FancyS ls)   = traverse_ (uncurry drawSegment) ls
+drawLine :: Line -> IO ()
+drawLine = traverse_ drawSegment
 
 -- | Write a single styled UTF-8 segment.  Safe because C only reads the bytes.
-drawSegment :: ByteString -> Style -> IO ()
-drawSegment bs sty = withStyle sty $ void $
+drawSegment :: Segment -> IO ()
+drawSegment (Seg sty bs) = withStyle sty $ void $
     P.unsafeUseAsCStringLen bs \(cstr, len) ->
         waddnstr Curses.stdScr cstr (fromIntegral len)
 
-
 ------------------------------------------------------------------------
 
-maybeLineDown :: StringA -> Int -> Int -> Int -> IO ()
-maybeLineDown (Fast s _) h y _ | s == P.empty = lineDown h y
-maybeLineDown _ h y x
-    | x == 0    = pure ()     -- already moved down
-    | otherwise = lineDown h y
-
-------------------------------------------------------------------------
-
-lineDown :: Int -> Int -> IO ()
-lineDown h y = Curses.wMove Curses.stdScr (min h (y+1)) 0
-
---
 -- | Fill to end of line spaces
---
+-- (Curses throws error if already at end.)
 fillLine :: IO ()
-fillLine = discardErrors Curses.clrToEol -- harmless?
-
---
--- | move cursor to origin of stdScr.
---
-gotoTop :: IO ()
-gotoTop = Curses.wMove Curses.stdScr 0 0
-{-# INLINE gotoTop #-}
+fillLine = discardErrors Curses.clrToEol
 
 -- | Take a slice of an array efficiently
 slice :: Int -> Int -> Array Int e -> [e]
 slice i j arr = 
     let (a, b) = bounds arr
-    in [unsafeAt arr n | n <- [max a i .. min b j] ]
-{-# INLINE slice #-}
+    in [unsafeAt arr n | n <- [max a i .. min b j]]
 
 ------------------------------------------------------------------------
 
---
 -- | magics for setting xterm titles using ansi escape sequences
---
 setXtermTitle :: [ByteString] -> IO ()
 setXtermTitle strs = do
     traverse_ (P.hPut stderr) (before : strs ++ [after])
@@ -555,13 +355,11 @@
 
 -- set xterm title.  Don't need to do this on each refresh...
 setXterm :: HState -> IO ()
-setXterm s = setXtermTitle $ case status s of
-    Playing -> case id3 s of
-        Nothing -> [fbase $ music s ! current s]
-        Just ti -> id3artist ti :
-                   if P.null (id3title ti)
-                        then []
-                        else [": ", id3title ti]
+setXterm st = setXtermTitle case st.status of
+    Playing -> case st.id3 of
+        Just id3 -> id3.artist :
+                       if P.null id3.title then [] else [": ", id3.title]
+        _        -> [(st.music ! st.current).fbase]
     Paused  -> ["paused"]
     Stopped -> ["stopped"]
 
diff --git a/Width.hs b/Width.hs
deleted file mode 100644
--- a/Width.hs
+++ /dev/null
@@ -1,45 +0,0 @@
--- Copyright (c) 2019-2026 Galen Huntington
--- SPDX-License-Identifier: GPL-2.0-or-later
-
--- | Width-aware operations on UTF-8 'ByteString's, using libc 'wcwidth'.
--- A UTF-8 runtime locale is presumed; counts may differ otherwise.
-module Width (displayWidth, toMaxWidth, toWidth) where
-
-import Base
-
-import Data.ByteString.Char8 qualified as P
-import Data.ByteString.UTF8 qualified as UTF8
-
-import Foreign.C.Types
-
-
--- | Sum of the column widths of every codepoint in a UTF-8 'ByteString'.
-displayWidth :: ByteString -> Int
-displayWidth = UTF8.foldl (\acc c -> acc + charWidth c) 0
-
--- | These functions truncate with ellipses if needed to get width ≤'w'.
--- 'toWidth' adds padding as needed so the width is exactly 'w'.
-toMaxWidth, toWidth :: Int -> ByteString -> ByteString
-toMaxWidth = sizer False
-toWidth = sizer True
-
-sizer :: Bool -> Int -> ByteString -> ByteString
-sizer pad w bs
-    | dw <= w = if pad then bs <> P.replicate (w-dw) ' ' else bs
-    | True    = walk 0 bs
-  where
-    dw = displayWidth bs
-    walk !l rest
-        | l' >= w = P.take (P.length bs - P.length rest) bs
-                        <> mconcat (replicate (w-l) $ UTF8.fromString "…")
-        | True    = walk l' rest'
-      where
-        (c, rest') = fromJust $ UTF8.uncons rest -- can't be at end since dw>w
-        l'         = l + charWidth c
-
-charWidth :: Char -> Int
-charWidth = fromIntegral . wcwidth . toEnum . fromEnum
-
-foreign import ccall safe
-    wcwidth :: CWchar -> CInt
-
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -8,7 +8,7 @@
 import Base
 
 import Core     (start, shutdown, Options(..))
-import Config qualified
+import Elements (fullVersion)
 import Keymap   (keyLoop)
 import Playlist (buildPlaylist, isEmpty)
 
@@ -60,15 +60,16 @@
         <*> option auto (
             long "history" <> short 'h' <> metavar "NUM" <> value 61
                 <> help "Size of play history, up to 61 selectable" <> showDefault)
+        <*> switch (long "random" <> help "Start on a random song")
     files = some $ argument (UTF8.fromString <$> str) (metavar "FILE|DIR...")
 
 parserInfo :: ParserInfo (Options, [ByteString])
 parserInfo = info (invocation <**> versionOpt <**> helper) $
        fullDesc
-    <> header Config.versinfo
+    <> header fullVersion
     <> progDesc "Play mp3 files in a curses interface."
   where
-    versionOpt = infoOption Config.versinfo
+    versionOpt = infoOption fullVersion
         (hidden <> long "version" <> short 'V' <> help "Show version information")
 
 -- XXX should this have tests?
diff --git a/hmp3-ng.cabal b/hmp3-ng.cabal
--- a/hmp3-ng.cabal
+++ b/hmp3-ng.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: hmp3-ng
-version: 2.19.0
+version: 2.19.1
 synopsis: A 2019 fork of an ncurses mp3 player written in Haskell
 description:
   An mp3 player with a curses frontend.  Playlists are populated by
@@ -15,7 +15,6 @@
 license: GPL-2.0-or-later
 license-file: LICENSE
 build-type: Simple
-
 extra-doc-files:
   README.md
 
@@ -28,32 +27,34 @@
   default-extensions:
     BlockArguments
     MultiWayIf
+    NoFieldSelectors
+    OverloadedRecordDot
     OverloadedStrings
     RecordWildCards
     -- In GHC2024
+    DataKinds
     DerivingStrategies
     LambdaCase
 
   ghc-options:
     -Wall
     -Wprepositive-qualified-module
-    -funbox-strict-fields
 
 library
   import: opts
   hs-source-dirs: ./
   exposed-modules:
     Base
-    Config
     Core
     Decoder
+    Elements
     Keyboard
     Keymap
     Playlist
     State
     Style
+    Text
     UI
-    Width
 
   other-modules:
     Paths_hmp3_ng
@@ -78,6 +79,7 @@
     process,
     random,
     regex-posix,
+    transformers,
     unix >=2.8,
     utf8-string,
 
@@ -99,13 +101,11 @@
   main-is: Main.hs
   hs-source-dirs: test
   other-modules:
-    BaseSpec
-    ConfigSpec
-    CoreSpec
     DecoderSpec
+    ElementsSpec
     PlaylistSpec
     StyleSpec
-    WidthSpec
+    TextSpec
 
   build-depends:
     base,
diff --git a/test/BaseSpec.hs b/test/BaseSpec.hs
deleted file mode 100644
--- a/test/BaseSpec.hs
+++ /dev/null
@@ -1,30 +0,0 @@
-module BaseSpec (tests) where
-
-import Data.ByteString.UTF8 qualified as UTF8
-
-import Test.Tasty
-import Test.Tasty.HUnit
-
-import Base (matches)
-
-tests :: TestTree
-tests = testGroup "Base"
-    [ testGroup "match"
-        [ t True "exact"         "foo"       "fooBar"
-        , t True "caseless"      "FOO"       "fooBar"
-        , t False "invalid"      "["         "any[thing"
-        , t True "alt"           "(foo|az)Q" "bazQux"
-        , t True "dot"           "o.a"       "foobar"
-        , t True "Unicode"       "jör"       "Björk"
-        , t True "dot Unicode"   "j.r"       "Björk"
-        , t True "Nordic case"   "bør"       "BØrnE"
-        , t True "Greek case"    "Λω"        "ΦλΩα"
-        , t True "dot CJK"       "中.人"     "中國人"
-        ]
-    ]
-
-
-t :: Bool -> String -> String -> String -> TestTree
-t b tag pat str =
-    testCase tag $ matches (UTF8.fromString pat) (UTF8.fromString str) @?= b
-
diff --git a/test/ConfigSpec.hs b/test/ConfigSpec.hs
deleted file mode 100644
--- a/test/ConfigSpec.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module ConfigSpec (tests) where
-
-import Control.Exception
-import Data.Either (isRight)
-
-import Test.Tasty
-import Test.Tasty.HUnit
-
-import Config (fixedStyles, defaultStyle)
-import Style (UIStyle(..), style)
-
-tests :: TestTree
-tests = testGroup "Config"
-    [ testGroup "styles"
-        [ testStyles "built-in styles valid" fixedStyles True
-        , testStyles "wrong style invalid" badStyles False
-        ]
-    ]
-
--- Check WHNF evaluation doesn't throw.
--- Suffices because UIStyle is recursively strict.
-evalOk :: a -> IO Bool
-evalOk = fmap isRight . try @SomeException . evaluate
-
-testStyles :: Traversable t => String -> t UIStyle -> Bool -> TestTree
-testStyles s m b = testCase s $ (@?= b) . and =<< traverse evalOk m
-
--- Test that test actually tests.
-badStyles :: [UIStyle]
-badStyles =
-    [ defaultStyle
-    , defaultStyle { window = style "badcolor" "default" }
-    , defaultStyle
-    ]
-
diff --git a/test/CoreSpec.hs b/test/CoreSpec.hs
deleted file mode 100644
--- a/test/CoreSpec.hs
+++ /dev/null
@@ -1,44 +0,0 @@
-module CoreSpec (tests) where
-
-import Test.Tasty
-import Test.Tasty.HUnit
-
-import System.Clock (TimeSpec(..))
-
-import Core (showTimeDiff_)
-
-tests :: TestTree
-tests = testGroup "Core"
-    [ testGroup "showTimeDiff_ (secs=False)"
-        [ testCase "under a minute is 0m"
-            $ showTimeDiff_ False (t 0) (t 30)    @?= "0m"
-        , testCase "exactly one minute"
-            $ showTimeDiff_ False (t 0) (t 60)    @?= "1m"
-        , testCase "under an hour"
-            $ showTimeDiff_ False (t 0) (t 599)   @?= "9m"
-        , testCase "exactly one hour"
-            $ showTimeDiff_ False (t 0) (t 3600)  @?= "1h00m"
-        , testCase "one hour, one minute, one second drops seconds"
-            $ showTimeDiff_ False (t 0) (t 3661)  @?= "1h01m"
-        , testCase "exactly one day"
-            $ showTimeDiff_ False (t 0) (t 86400) @?= "1d00h00m"
-        , testCase "day, hour, minute"
-            $ showTimeDiff_ False (t 0) (t 90060) @?= "1d01h01m"
-        ]
-    , testGroup "showTimeDiff_ (secs=True)"
-        [ testCase "thirty seconds"
-            $ showTimeDiff_ True (t 0) (t 30)      @?= "30s"
-        , testCase "one minute exactly appends 00s"
-            $ showTimeDiff_ True (t 0) (t 60)      @?= "1m00s"
-        , testCase "one minute thirty seconds"
-            $ showTimeDiff_ True (t 0) (t 90)      @?= "1m30s"
-        , testCase "one hour one minute one second"
-            $ showTimeDiff_ True (t 0) (t 3661)    @?= "1h01m01s"
-        , testCase "diff is taken from monotonic delta, not absolute values"
-            $ showTimeDiff_ True (t 1000) (t 1090) @?= "1m30s"
-        ]
-    ]
-
--- Build a TimeSpec from a whole number of seconds.
-t :: Integer -> TimeSpec
-t s = TimeSpec (fromInteger s) 0
diff --git a/test/DecoderSpec.hs b/test/DecoderSpec.hs
--- a/test/DecoderSpec.hs
+++ b/test/DecoderSpec.hs
@@ -5,9 +5,10 @@
 
 import Data.ByteString.Char8 qualified as P
 
+import Base
 import Decoder
 
--- These exercise the helpers doX, 'trim', and 'normalise'.
+-- These exercise the helpers doX and 'trim'
 tests :: TestTree
 tests = testGroup "Lexer.mpgParser"
     [ testGroup "status (@P)"
@@ -19,8 +20,8 @@
         , tc "@P "   $ Left Nothing   -- no payload
         ]
     , testGroup "frame (@F)"
-        [ tc "@F 123 456 12.34 56.78" $ Right (F (Frame 123 456 12.34 56.78))
-        , tc "@F 0 0 0.00 -1.00"      $ Right (F (Frame 0 0 0.00 0))  -- timeLeft clamped
+        [ tc "@F 123 456 12.34 56.78" $ Right (F (Frame 12.34 56.78))
+        , tc "@F 0 0 0.00 -1.00"      $ Right (F (Frame 0.00 0))  -- .left clamped
         , tc "@F 1 2 3.00"            $ Left Nothing   -- too few fields
         , tc "@F a b c d"             $ Left Nothing   -- non-numeric
         ]
@@ -29,7 +30,7 @@
                                       $ Right (S "mpeg 1.0 128kb/s 44kHz")
         , tc "@S 1.0 1 44100"         $ Left Nothing   -- too few fields
         ]
-    , testGroup "id3 (@I)" (let s = "n\195\182rmalise" in
+    , testGroup "id3 (@I)"
         [ tcId3 ["Title"]
               $ Right (I (Id3 "Title" "" "" "Title"))
         , tcId3 ["Title", "Artist"]
@@ -41,9 +42,7 @@
         , tcId3 ["", "Artist"]       $ Left Nothing   -- blank title: skipped
         , tc "@I song.mp3"           $ Left Nothing   -- non-ID3 @I: don't overwrite
         , tc "@I {"                  $ Left Nothing   -- grouping marker: ignored
-        , tcId3 ["nörmalise"]        $ Right (I (Id3 s "" "" s))
-        , tcId3 [s]                  $ Right (I (Id3 s "" "" s))
-        ])
+        ]
     , testGroup "tagline, errors, junk"
         [ tc "@E some failure"       $ Left (Just "some failure")
         , tc "garbage"               $ Left Nothing   -- no @ prefix
@@ -58,6 +57,6 @@
     tcId3 fields = tc' (show fields) (id3 fields)
 
 -- | Build an "@I ID3:" line from fixed-width 30-char fields, as mpg123 emits.
-id3 :: [P.ByteString] -> P.ByteString
+id3 :: [ByteString] -> ByteString
 id3 fields = "@I ID3:" <> mconcat [ P.take 30 (f <> P.replicate 30 ' ') | f <- fields ]
 
diff --git a/test/ElementsSpec.hs b/test/ElementsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ElementsSpec.hs
@@ -0,0 +1,74 @@
+module ElementsSpec (tests) where
+
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Data.ByteString.Char8 qualified as P
+import System.Clock (TimeSpec(..))
+
+import Base
+import Text (displayWidth)
+import Elements (showDuration, fitLCR, layoutLCR, Fit(..))
+
+tests :: TestTree
+tests = testGroup "Elements"
+    [ testGroup "showDuration (showSecs=False)"
+        [ testCase "under a minute is 0m"
+            $ showDuration False (t 30)    @?= "0m"
+        , testCase "exactly one minute"
+            $ showDuration False (t 60)    @?= "1m"
+        , testCase "under an hour"
+            $ showDuration False (t 599)   @?= "9m"
+        , testCase "exactly one hour"
+            $ showDuration False (t 3600)  @?= "1h00m"
+        , testCase "one hour, one minute, one second drops seconds"
+            $ showDuration False (t 3661)  @?= "1h01m"
+        , testCase "exactly one day"
+            $ showDuration False (t 86400) @?= "1d00h00m"
+        , testCase "day, hour, minute"
+            $ showDuration False (t 90060) @?= "1d01h01m"
+        ]
+    , testGroup "showDuration (showSecs=True)"
+        [ testCase "thirty seconds"
+            $ showDuration True (t 30)     @?= "30s"
+        , testCase "one minute exactly appends 00s"
+            $ showDuration True (t 60)     @?= "1m00s"
+        , testCase "one minute thirty seconds"
+            $ showDuration True (t 90)     @?= "1m30s"
+        , testCase "one hour one minute one second"
+            $ showDuration True (t 3661)   @?= "1h01m01s"
+        ]
+    , testCase "Title layout" fitTests
+    ]
+
+-- Build a TimeSpec from a whole number of seconds.
+t :: Integer -> TimeSpec
+t s = TimeSpec (fromInteger s) 0
+
+fitTests :: Assertion
+fitTests = sequence_ do
+    w <- [1..40]
+    lsz <- [0..9]
+    csz <- [0,1,4,5,6,8]  -- always 6 currently
+    rsz <- [0..9]
+    let inp = (lsz, csz, rsz)
+    let ans@Fit{..} = fitLCR w inp
+    let go as wh = as (wh ++ " " ++ show (w, inp) ++ " -> " ++ show ans)
+    pure do
+        -- Check several invariants.
+        go assertBool "Positive padl" $ padL > 0
+        go assertBool "Positive padr" $ padR > 0 || (ctake == 0 && padR == 0)
+        go assertBool "Nonnegative ctake" $ ctake >= 0
+        go assertEqual "Total width" w $
+            padL + padR + ctake + (if wide then lsz + rsz else 0)
+        go assertEqual "Show iff possible" wide (lsz + csz + rsz + 2 <= w)
+        when (padL > 1 && padR > 1) do
+            go assertEqual "Full center if possible" ctake csz
+            go assertBool "Centered if possible" $
+                let a = padL + (if wide then lsz else 0)
+                    b = padR + (if wide then rsz else 0)
+                in a == b || a + 1 == b
+        let s = layoutLCR w (P.replicate lsz 'x', replicate csz 'x', P.replicate rsz 'x')
+        assertEqual ("String width: " ++ show inp ++ " -> " ++ show s) w
+            $ displayWidth s
+
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -2,22 +2,18 @@
 
 import Test.Tasty
 
-import BaseSpec qualified
-import ConfigSpec qualified
-import CoreSpec qualified
+import ElementsSpec qualified
 import DecoderSpec qualified
 import PlaylistSpec qualified
 import StyleSpec qualified
-import WidthSpec qualified
+import TextSpec qualified
 
 main :: IO ()
 main = defaultMain $ testGroup "hmp3-ng"
-    [ BaseSpec.tests
-    , ConfigSpec.tests
-    , CoreSpec.tests
+    [ ElementsSpec.tests
     , DecoderSpec.tests
     , StyleSpec.tests
     , PlaylistSpec.tests
-    , WidthSpec.tests
+    , TextSpec.tests
     ]
 
diff --git a/test/StyleSpec.hs b/test/StyleSpec.hs
--- a/test/StyleSpec.hs
+++ b/test/StyleSpec.hs
@@ -3,6 +3,9 @@
 import Test.Tasty
 import Test.Tasty.HUnit
 
+import Control.Exception
+
+import Base (isRight)
 import Style
 
 -- Lock in the config-string -> Color mapping and, with it, the intensity
@@ -32,5 +35,25 @@
         , testCase "unknown name"
             $ stringToColor "chartreuse" @?= Nothing
         ]
+    , testGroup "styles"
+        [ testStyles "built-in styles valid" [defaultStyle, monoStyle] True
+        , testStyles "wrong style invalid" badStyles False
+        ]
+    ]
+
+-- Check WHNF evaluation doesn't throw.
+-- Suffices because UIStyle is recursively strict.
+evalOk :: a -> IO Bool
+evalOk = fmap isRight . try @SomeException . evaluate
+
+testStyles :: Traversable t => String -> t UIStyle -> Bool -> TestTree
+testStyles s m b = testCase s $ (@?= b) . and =<< traverse evalOk m
+
+-- Test that test actually tests.
+badStyles :: [UIStyle]
+badStyles =
+    [ defaultStyle
+    , defaultStyle { window = style "badcolor" "default" }
+    , defaultStyle
     ]
 
diff --git a/test/TextSpec.hs b/test/TextSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/TextSpec.hs
@@ -0,0 +1,94 @@
+module TextSpec (tests) where
+
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Text
+
+-- These tests depend on wcwidth's behavior under a UTF-8 locale and on a
+-- handful of codepoints whose canonical widths are well-known:
+--   * ASCII chars are 1 column.
+--   * Latin-Extended chars (é, ñ) are 1 column.
+--   * Common CJK chars (中) are 2 columns.
+--   * The horizontal ellipsis (…) is 1 column.
+-- They are not deterministic under the C locale.
+
+tests :: TestTree
+tests = testGroup "Text"
+    [ testGroup "match"
+        [ m (Just True)  "exact"         "foo"       "fooBar"
+        , m (Just True)  "caseless"      "FOO"       "fooBar"
+        , m Nothing      "invalid"       "["         "any[thing"
+        , m (Just True)  "alt"           "(foo|az)Q" "bazQux"
+        , m (Just True)  "dot"           "o.a"       "foobar"
+        , m (Just True)  "Unicode"       "jör"       "Björk"
+        , m (Just True)  "dot Unicode"   "j.r"       "Björk"
+        , m (Just True)  "Nordic case"   "bør"       "BØrnE"
+        , m (Just True)  "Greek case"    "Λω"        "ΦλΩα"
+        , m (Just True)  "dot CJK"       "中.人"     "中國人"
+        , m (Just False) "byte dots"     "c..te"     "côte"
+        ]
+    , testGroup "dropLastUTF8"
+        [ testCase "ASCII"    $ dropLastUTF8 "abc"          @?= "ab"
+        , testCase "empty"    $ dropLastUTF8 ""             @?= ""
+        , testCase "French"   $ dropLastUTF8 (u"été")       @?= u"ét"
+        , testCase "CJK"      $ dropLastUTF8 (u"中國")      @?= u"中"
+        , testCase "Emoji"    $ dropLastUTF8 (u"Yes👍")     @?= u"Yes"
+        , testCase "invalid"  $ dropLastUTF8 "\x80"         @?= ""
+        ]
+    , testGroup "trim"
+        [ testCase "empty"      $ trim ""                   @?= ""
+        , testCase "whitespace" $ trim "  \tfoo bar \n"     @?= "foo bar"
+        ]
+    , testGroup "guessEncoding"
+        [ testCase "ASCII"    $ guessEncoding "abc"         @?= "abc"
+        , testCase "ISO-8859" $ guessEncoding "encöde"      @?= u"encöde"
+        , testCase "UTF-8"    $ guessEncoding (u"encöde")   @?= u"encöde"
+        ]
+    , testGroup "displayWidth"
+        [ testCase "empty"             $ displayWidth ""              @?= 0
+        , testCase "ascii"             $ displayWidth "hello"         @?= 5
+        , testCase "latin-extended"    $ displayWidth (u"café")       @?= 4
+        , testCase "cjk doubles each"  $ displayWidth (u"中文")       @?= 4
+        , testCase "mixed"             $ displayWidth (u"中a文b")     @?= 6
+        ]
+    , testGroup "toMaxWidth"
+        [ testCase "wider than input passes through"
+            $ toMaxWidth 10 "hello"    @?= "hello"
+        , testCase "exactly the width passes through"
+            $ toMaxWidth 5 "hello"     @?= "hello"
+        , testCase "truncate ascii with ellipsis"
+            $ toMaxWidth 4 "hello"     @?= "hel" <> u"…"
+        , testCase "narrower truncate"
+            $ toMaxWidth 2 "hello"     @?= "h"   <> u"…"
+        , testCase "width one becomes a lone ellipsis"
+            $ toMaxWidth 1 "hello"     @?= u"…"
+        , testCase "width zero becomes empty"
+            $ toMaxWidth 0 "hello"     @?= ""
+        , testCase "wide char truncation respects boundaries"
+            -- "中文hi" is 6 columns (2+2+1+1); toMaxWidth 4 keeps the first
+            -- wide char plus two ellipses to fill the remaining columns.
+            $ toMaxWidth 4 (u"中文hi") @?= u"中……"
+        , testCase "wide char gives way to single ellipsis at the boundary"
+            -- "中文" is 4 columns; toMaxWidth 3 keeps the first wide char
+            -- (2 columns) plus one ellipsis (1 column).
+            $ toMaxWidth 3 (u"中文")   @?= u"中…"
+        ]
+    , testGroup "toWidth"
+        [ testCase "pads short ascii"
+            $ toWidth 10 "hello"       @?= "hello     "
+        , testCase "pad with empty input"
+            $ toWidth 4 ""             @?= "    "
+        , testCase "exact width unchanged"
+            $ toWidth 5 "hello"        @?= "hello"
+        , testCase "truncate matches toMaxWidth when over-width"
+            $ toWidth 4 "hello"        @?= u"hel…"
+        , testCase "pads after a wide-char content too"
+            $ toWidth 5 (u"中a")       @?= u"中a  "
+        ]
+    ]
+
+
+m :: Maybe Bool -> String -> String -> String -> TestTree
+m b tag pat str = testCase tag $ ($ u str) <$> matches (u pat) @?= b
+
diff --git a/test/WidthSpec.hs b/test/WidthSpec.hs
deleted file mode 100644
--- a/test/WidthSpec.hs
+++ /dev/null
@@ -1,65 +0,0 @@
-module WidthSpec (tests) where
-
-import Test.Tasty
-import Test.Tasty.HUnit
-
-import Data.ByteString (ByteString)
-import Data.ByteString.UTF8 qualified as UTF8
-
-import Width (displayWidth, toMaxWidth, toWidth)
-
--- These tests depend on wcwidth's behaviour under a UTF-8 locale and on a
--- handful of codepoints whose canonical widths are well-known:
---   * ASCII chars are 1 column.
---   * Latin-Extended chars (é, ñ) are 1 column.
---   * Common CJK chars (中) are 2 columns.
---   * The horizontal ellipsis (…) is 1 column.
--- They are not deterministic under the C locale.
-
-tests :: TestTree
-tests = testGroup "Width"
-    [ testGroup "displayWidth"
-        [ testCase "empty"             $ displayWidth ""              @?= 0
-        , testCase "ascii"             $ displayWidth "hello"         @?= 5
-        , testCase "latin-extended"    $ displayWidth (u"café")       @?= 4
-        , testCase "cjk doubles each"  $ displayWidth (u"中文")       @?= 4
-        , testCase "mixed"             $ displayWidth (u"中a文b")     @?= 6
-        ]
-    , testGroup "toMaxWidth"
-        [ testCase "wider than input passes through"
-            $ toMaxWidth 10 "hello"    @?= "hello"
-        , testCase "exactly the width passes through"
-            $ toMaxWidth 5 "hello"     @?= "hello"
-        , testCase "truncate ascii with ellipsis"
-            $ toMaxWidth 4 "hello"     @?= "hel" <> u"…"
-        , testCase "narrower truncate"
-            $ toMaxWidth 2 "hello"     @?= "h"   <> u"…"
-        , testCase "width one becomes a lone ellipsis"
-            $ toMaxWidth 1 "hello"     @?= u"…"
-        , testCase "width zero becomes empty"
-            $ toMaxWidth 0 "hello"     @?= ""
-        , testCase "wide char truncation respects boundaries"
-            -- "中文hi" is 6 columns (2+2+1+1); toMaxWidth 4 keeps the first
-            -- wide char plus two ellipses to fill the remaining columns.
-            $ toMaxWidth 4 (u"中文hi") @?= u"中……"
-        , testCase "wide char gives way to single ellipsis at the boundary"
-            -- "中文" is 4 columns; toMaxWidth 3 keeps the first wide char
-            -- (2 columns) plus one ellipsis (1 column).
-            $ toMaxWidth 3 (u"中文")   @?= u"中…"
-        ]
-    , testGroup "toWidth"
-        [ testCase "pads short ascii"
-            $ toWidth 10 "hello"       @?= "hello     "
-        , testCase "pad with empty input"
-            $ toWidth 4 ""             @?= "    "
-        , testCase "exact width unchanged"
-            $ toWidth 5 "hello"        @?= "hello"
-        , testCase "truncate matches toMaxWidth when over-width"
-            $ toWidth 4 "hello"        @?= "hel" <> u"…"
-        , testCase "pads after a wide-char content too"
-            $ toWidth 5 (u "中a")      @?= u"中a" <> "  "
-        ]
-    ]
-
-u :: String -> ByteString
-u = UTF8.fromString
