packages feed

nano-ui-0.1.0.1: lib/NanoUI/Font.hs

{-# LANGUAGE StrictData #-}

module NanoUI.Font
  ( GlyphQuad (..)
  , ShapedText (..)
  , ShapedGlyphs (..)
  , FontMetrics (..)
  , FontBackend (..)
  , CustomMeasureFn
  , prepareFontMetrics
  , prepareFontMetricsMany
  , measureTextIO
  , lineWidthIO
  , drawShaped
  , drawGlyph
  , caretX
  , caretXIO
  , selectionSpans
  , monospaceMetrics
  , scaleFontMetrics
  , measureTextWrappedIO
  , wrapTextLinesIO
  , truncateTextIO
  , lineWidth
  , kernedAdvance
  , textIndexAtX
  , tableCellInset
  , widgetContentInset
  , widgetPadding
  , buttonPadding
  , selectPadding
  , menuOuterPad
  , menuItemPadX
  , menuItemRowH
  , menuSepH
  , menuMinW
  , menuAccentW
  , menuAccentInset
  , centeredTextY
  , alignedTextPen
  , textInkEnd
  , isDefaultNodeFont
  , checkboxBoxSize
  , checkboxLeading
  , treeItemPadding
  , treeRowLeading
  , treeChevronRect
  , scrollBarWidth
  , scrollBarSideGap
  , scrollBarGeomFor
  , scrollBarGap
  , scrollBarGutter
  , ScrollBarSlot (..)
  , classifyScrollBar
  , scrollLayoutGutter
  , sliderTrackBounds
  , sliderTrackHeight
  , sliderHandleDiameter
  , sliderHandleSlack
  ) where

import qualified Data.Map.Strict as Map
import Data.Primitive.PrimArray (PrimArray, imapPrimArray, indexPrimArray, mapPrimArray, sizeofPrimArray)
import Data.Text (Text)
import qualified Data.Text as T
import NanoUI.Types (Rect (..), onGrid)
import NanoUI.Style (AlignX (..), FontStyle (..), FontVariant (..), FontWeight (..))

data GlyphQuad = GlyphQuad
  { gqX :: {-# UNPACK #-} !Float
  , gqY :: {-# UNPACK #-} !Float
  , gqW :: {-# UNPACK #-} !Float
  , gqH :: {-# UNPACK #-} !Float
  , gqU0 :: {-# UNPACK #-} !Float
  , gqV0 :: {-# UNPACK #-} !Float
  , gqU1 :: {-# UNPACK #-} !Float
  , gqV1 :: {-# UNPACK #-} !Float
  }
  deriving (Eq, Show)

-- | A line of text as the host's shaper laid it out: glyphs chosen and placed
-- with the font's kerning, ligatures and contextual forms, in fallback fonts
-- where the font lacks a character, and right-to-left runs reordered.
data ShapedText = ShapedText
  { stAdvance :: {-# UNPACK #-} !Float
  , stInkEnd :: {-# UNPACK #-} !Float
  -- ^ The right edge of the rightmost glyph's ink.
  , stCarets :: !(PrimArray Float)
  -- ^ Where the caret sits before each character, and after the last: one
  -- more entry than the text has characters. A right-to-left run's carets
  -- decrease, and the characters of a cluster share its width.
  }
  deriving (Eq, Show)

-- | The glyph quads that draw a shaped line: eight numbers a glyph (x, y,
-- width and height from the pen, then the atlas UVs u0 v0 u1 v1), in logical
-- pixels. Valid until the host's glyph atlas next resets.
newtype ShapedGlyphs = ShapedGlyphs (PrimArray Float)
  deriving (Eq, Show)

data FontMetrics = FontMetrics
  { fmLineHeight :: {-# UNPACK #-} !Float
  , fmAscent :: {-# UNPACK #-} !Float
  -- | Device pixels per logical unit used to snap glyph quads to the pixel
  -- grid. The SDL backend sets this to the window pixel density so text lands on
  -- whole device pixels.
  , fmSnapScale :: {-# UNPACK #-} !Float
  , fmAdvance :: Char -> Float
  , fmKerning :: Char -> Char -> Float
  , fmShape :: Text -> Maybe ShapedText
  -- ^ The shaped layout of a text the snapshot was prepared for, when the
  -- host shapes. Other texts fall back to 'fmAdvance' and 'fmKerning'.
  , fmGlyph :: Char -> Maybe GlyphQuad
  -- | Optional effectful backend. Pure callbacks above are immutable metric
  -- snapshots; they must never perform font loading or atlas mutation.
  , fmBackend :: Maybe FontBackend
  }

-- | Text preparation performs font queries in IO and returns an immutable
-- snapshot for pure layout. Rasterisation is separate and occurs during draw.
data FontBackend = FontBackend
  { fbPrepare :: Text -> IO FontMetrics
  , fbDrawShaped :: Text -> IO (Maybe ShapedGlyphs)
  , fbDrawGlyph :: Char -> IO (Maybe GlyphQuad)
  }

-- | Custom node measurement: font metrics and available (width, height) to
-- the node's desired (width, height).
type CustomMeasureFn = FontMetrics -> (Float, Float) -> (Float, Float)

{-# INLINE prepareFontMetrics #-}
prepareFontMetrics :: FontMetrics -> Text -> IO FontMetrics
prepareFontMetrics fm txt = case fmBackend fm of
  Nothing -> pure fm
  Just backend -> fbPrepare backend txt

-- | Prepare a finite text workspace for pure multi-label layout algorithms.
prepareFontMetricsMany :: FontMetrics -> [Text] -> IO FontMetrics
prepareFontMetricsMany fm texts = case fmBackend fm of
  Nothing -> pure fm
  Just _ -> do
    combined <- prepareFontMetrics fm (T.intercalate "\n" texts)
    shapes <- mapM (\t -> do
      prepared <- prepareFontMetrics fm t
      pure (t, fmShape prepared t)) texts
    let !byText = Map.fromList shapes
    pure combined {fmShape = \t -> Map.findWithDefault Nothing t byText}

{-# INLINE lineWidthIO #-}
lineWidthIO :: FontMetrics -> Text -> IO Float
lineWidthIO fm txt = do
  prepared <- prepareFontMetrics fm txt
  pure $! lineWidth prepared txt

{-# INLINE measureTextIO #-}
measureTextIO :: FontMetrics -> Text -> IO (Float, Float)
measureTextIO fm txt = do
  prepared <- prepareFontMetrics fm txt
  pure $! measureText prepared txt

-- | The glyph quads of a shaped line, placing glyphs in the host's atlas as
-- needed; 'Nothing' when the host does not shape.
{-# INLINE drawShaped #-}
drawShaped :: FontMetrics -> Text -> IO (Maybe ShapedGlyphs)
drawShaped fm txt = case fmBackend fm of
  Nothing -> pure Nothing
  Just backend -> fbDrawShaped backend txt

{-# INLINE drawGlyph #-}
drawGlyph :: FontMetrics -> Char -> IO (Maybe GlyphQuad)
drawGlyph fm c = case fmBackend fm of
  Nothing -> pure (fmGlyph fm c)
  Just backend -> fbDrawGlyph backend c

monospaceMetrics :: Float -> FontMetrics
monospaceMetrics cell =
  FontMetrics
    { fmLineHeight = cell
    , fmAscent = cell * 0.8
    , fmSnapScale = 1.0
    , fmAdvance = \_ -> cell
    , fmKerning = \_ _ -> 0
    , fmShape = \_ -> Nothing
    , fmGlyph = \_ -> Nothing
    , fmBackend = Nothing
    }

scaleFontMetrics :: Float -> FontMetrics -> FontMetrics
scaleFontMetrics s fm
  | s == 1.0 = fm
  | otherwise =
      FontMetrics
        { fmLineHeight = fmLineHeight fm * s
        , fmAscent = fmAscent fm * s
        -- Snap scale is a display property, not a font-size property.
        , fmSnapScale = fmSnapScale fm
        , fmAdvance = \c -> fmAdvance fm c * s
        , fmKerning = \a b -> fmKerning fm a b * s
        , fmShape = \t -> fmap scaleShape (fmShape fm t)
        , fmGlyph = fmap scaleGlyph . fmGlyph fm
        , fmBackend = fmap scaleBackend (fmBackend fm)
        }
  where
    scaleBackend backend = FontBackend
      { fbPrepare = \t -> scaleFontMetrics s <$> fbPrepare backend t
      , fbDrawShaped = \t -> fmap (fmap scaleGlyphs) (fbDrawShaped backend t)
      , fbDrawGlyph = \c -> fmap (fmap scaleGlyph) (fbDrawGlyph backend c)
      }
    scaleGlyph gq = gq
      { gqX = gqX gq * s, gqY = gqY gq * s
      , gqW = gqW gq * s, gqH = gqH gq * s
      }
    scaleShape st =
      st
        { stAdvance = stAdvance st * s
        , stInkEnd = stInkEnd st * s
        , stCarets = mapPrimArray (* s) (stCarets st)
        }
    -- UVs stay in normalised atlas space; only positions and sizes scale.
    scaleGlyphs (ShapedGlyphs quads) =
      ShapedGlyphs (imapPrimArray (\i v -> if i `mod` 8 < 4 then v * s else v) quads)

-- | Horizontal text inset of a table cell. Zebra and header fills use the full cell rect.
tableCellInset :: Float
tableCellInset = 6

{-# INLINE widgetContentInset #-}
widgetContentInset :: FontMetrics -> (Float, Float)
widgetContentInset fm =
  let pad = fmAdvance fm ' ' * 1.25
   in (pad, pad)

{-# INLINE buttonPadding #-}
buttonPadding :: FontMetrics -> (Float, Float)
buttonPadding fm =
  let adv = fmAdvance fm ' '
      lh = fmLineHeight fm
   in (adv * 2.0, lh * 0.30)

{-# INLINE selectPadding #-}
selectPadding :: FontMetrics -> (Float, Float)
selectPadding fm =
  let adv = fmAdvance fm ' '
      lh = fmLineHeight fm
   in (adv * 2.0, lh * 0.50)

-- Menu metrics shared by the text-field context menu painter, the generic
-- context-menu widgets, and the layout/paint passes, so both menus render
-- identically by construction.

-- | Blank border between the menu panel edge and its rows.
menuOuterPad :: Float
menuOuterPad = 6

-- | Extra horizontal inset of a menu row's label past 'menuOuterPad'.
menuItemPadX :: Float
menuItemPadX = 10

-- | Fixed height of one menu row.
menuItemRowH :: Float
menuItemRowH = 28

-- | Height of a separator band inside a menu.
menuSepH :: Float
menuSepH = 9

-- | Floor for the menu panel width.
menuMinW :: Float
menuMinW = 148

-- | Width of the hover accent marker painted at a menu row's left edge.
menuAccentW :: Float
menuAccentW = 2

-- | Gap between the hover accent marker and the row's top and bottom edges.
menuAccentInset :: Float
menuAccentInset = 3

{-# INLINE centeredTextY #-}
centeredTextY :: FontMetrics -> Float -> Float -> Float -> Float
centeredTextY fm y h th =
  case fmGlyph fm 'H' of
    Nothing -> y + (h - th) / 2
    Just gq -> y + onGrid (fmSnapScale fm) (h / 2 - (gqY gq + gqH gq / 2))
  where
    -- Snap the (constant) baseline offset to the device grid rather than the
    -- whole pen: pen = snap(y + offset) rounds a fractional offset with ties
    -- to even, so adjacent rows (and the same row across a sub-pixel scroll)
    -- land on alternating device pixels while the geometry beside them stays
    -- rigid. Snapping only the constant offset keeps every row fixed on the
    -- grid no matter where y falls.

-- Origin and used width inside the node box, inset on all AlignX sides.
{-# INLINE alignedTextBox #-}
alignedTextBox :: AlignX -> Float -> Float -> Float -> Float -> (Float, Float)
alignedTextBox ax x w ix tw =
  let contentW = max 0 (w - 2 * ix)
      used = min tw contentW
      tx = case ax of
        AlignEnd -> x + w - ix - used
        AlignCenter -> x + ix + (contentW - used) / 2
        AlignStart -> x + ix
   in (tx, used)

-- Last glyph ink right in the same space as 'pushText' (pen + gqX + gqW).
-- Falls back to advance when 'fmGlyph' is Nothing (tests).
textInkEnd :: FontMetrics -> Text -> Float
textInkEnd fm txt =
  case T.unsnoc txt of
    Nothing -> 0
    Just (prefix, c) ->
      case fmShape fm txt of
        Just st -> stInkEnd st
        Nothing ->
          let pen = lineWidth fm prefix
           in case fmGlyph fm c of
                Just gq -> pen + gqX gq + gqW gq
                Nothing -> pen + fmAdvance fm c

-- Align using per-glyph advances (same as 'pushText'), not TTF_GetStringSize.
-- When the line fits, AlignEnd/Center shift by ink so the visual right edge
-- stays put as the last character's right bearing changes.
alignedTextPen :: AlignX -> Float -> Float -> Float -> FontMetrics -> Text -> (Float, Float)
alignedTextPen ax x w ix fm txt =
  let tw = lineWidth fm txt
      ink = textInkEnd fm txt
      contentW = max 0 (w - 2 * ix)
      used = min tw contentW
      shift =
        if tw > contentW
          then used
          else case ax of
            AlignStart -> used
            _ -> ink
      (tx, _) = alignedTextBox ax x w ix shift
   in (tx, used)

{-# INLINE widgetPadding #-}
widgetPadding :: FontMetrics -> (Float, Float)
widgetPadding fm =
  let (cx, cy) = widgetContentInset fm
   in (2 * cx, 2 * cy)

{-# INLINE checkboxBoxSize #-}
checkboxBoxSize :: FontMetrics -> Float
checkboxBoxSize fm = min 22 (max 18 (fmLineHeight fm * 1.15))

{-# INLINE checkboxLeading #-}
checkboxLeading :: FontMetrics -> Float
checkboxLeading fm = checkboxBoxSize fm + 8

{-# INLINE treeItemPadding #-}
treeItemPadding :: FontMetrics -> (Float, Float)
treeItemPadding fm =
  let lh = fmLineHeight fm
   in (0, max 8 (fromIntegral (round (lh * 0.40) :: Int)))

{-# INLINE treeIndentStep #-}
treeIndentStep :: FontMetrics -> Float
treeIndentStep fm = max 12 (fmLineHeight fm * 0.85)

{-# INLINE treeChevronLeading #-}
treeChevronLeading :: FontMetrics -> Float
treeChevronLeading fm = checkboxBoxSize fm + 6

{-# INLINE treeRowLeading #-}
treeRowLeading :: FontMetrics -> Int -> Float
treeRowLeading fm depth =
  treeIndentStep fm * fromIntegral (max 0 depth) + treeChevronLeading fm

{-# INLINE treeChevronRect #-}
treeChevronRect :: FontMetrics -> Float -> Float -> Float -> Float -> Int -> Rect
treeChevronRect fm x y _w h depth =
  let indent = treeIndentStep fm * fromIntegral (max 0 depth)
      lead = max 1 (treeChevronLeading fm)
   in Rect (x + indent) y lead h

sliderTrackHeight :: Float
sliderTrackHeight = 10

sliderHandleDiameter :: Float
sliderHandleDiameter = 18

sliderHandleSlack :: Float
sliderHandleSlack = (sliderHandleDiameter - sliderTrackHeight) / 2

{-# INLINE sliderTrackBounds #-}
sliderTrackBounds :: Float -> Float -> Float -> Float -> Rect
sliderTrackBounds x y w h =
  let trackY = y + max 0 ((h - sliderTrackHeight) / 2)
   in Rect x trackY (max 0 w) sliderTrackHeight

-- | Thickness of a list or page scrollbar.
scrollBarWidth :: Float
scrollBarWidth = 8

-- Window bodies take a slimmer bar.
scrollBarSlimWidth :: Float
scrollBarSlimWidth = 4

scrollBarMargin :: Float
scrollBarMargin = 3

-- | The sliver between a page or window bar and the outer edge, and the
-- smallest gap on either side of a list bar.
scrollBarSideGap :: Float
scrollBarSideGap = 3

-- | Bar width and end margin for a slot.
scrollBarGeomFor :: ScrollBarSlot -> (Float, Float)
scrollBarGeomFor slot =
  case slot of
    ScrollBarList -> (scrollBarWidth, scrollBarMargin)
    ScrollBarPage -> (scrollBarWidth, scrollBarMargin)
    -- Window bar: side gaps only. No end inset.
    ScrollBarWindow -> (scrollBarSlimWidth, 0)

-- | The layout arena stores a scroller's slot as its 'Enum' value, and every
-- other node reads a zero there, so 'ScrollBarList' comes first.
data ScrollBarSlot = ScrollBarList | ScrollBarPage | ScrollBarWindow
  deriving (Eq, Show, Enum)

classifyScrollBar :: Bool -> Bool -> ScrollBarSlot
classifyScrollBar isWindowBody isPageGrow
  | isWindowBody = ScrollBarWindow
  | isPageGrow = ScrollBarPage
  | otherwise = ScrollBarList

-- | Gap between the content and a bar, given the padding @trailPad@ on the
-- bar's side: the padding itself, never under 'scrollBarSideGap'.
scrollBarGap :: Float -> Float
scrollBarGap trailPad = max scrollBarSideGap trailPad

-- | Space an overflowing scroller takes from its content, beside the padding
-- @trailPad@ on the bar's side, so the content stops one gap before the bar.
-- A list bar keeps a gap to its well's edge as well. A page bar sits a side
-- gap inside the page's edge. A window body's bar sits out in the window's
-- padding, a side gap inside the window's edge, so that padding is the gap
-- and only the bar and the side gap come out of the content.
scrollBarGutter :: ScrollBarSlot -> Float -> Float
scrollBarGutter slot trailPad =
  let (barW, _) = scrollBarGeomFor slot
      gap = scrollBarGap trailPad
   in case slot of
        ScrollBarList -> barW + 2 * gap - trailPad
        ScrollBarPage -> barW + scrollBarSideGap + gap - trailPad
        ScrollBarWindow -> barW + scrollBarSideGap

scrollLayoutGutter :: ScrollBarSlot -> Float -> Float -> Float -> Float
scrollLayoutGutter slot trailPad contentSize innerMain
  | contentSize <= innerMain = 0
  | otherwise = scrollBarGutter slot trailPad

measureText :: FontMetrics -> Text -> (Float, Float)
measureText fm txt =
  let h = fmLineHeight fm
      w = lineWidth fm txt
   in (w, h)

-- | The one policy for "does this node use the ambient base font, or does it
-- need the host resolver?". A zero size with a plain weight/style and the
-- regular or mono variant resolves to the pre-read base metrics; everything
-- else (heading/muted/danger, bold, italic, explicit size) defers to the host.
-- Layout, paint, span placement and hit testing all share this so they cannot
-- pick different faces for the same node.
{-# INLINE isDefaultNodeFont #-}
isDefaultNodeFont :: Float -> FontWeight -> FontStyle -> FontVariant -> Bool
isDefaultNodeFont size weight style variant =
  size <= 0
    && weight == WeightNormal
    && style == FontStyleNormal
    && (variant == FontRegular || variant == FontMono)

-- | Advance of @c@ plus its kerning against the previous character: the one
-- pen step shared by measuring, hit testing and glyph emission.
{-# INLINE kernedAdvance #-}
kernedAdvance :: FontMetrics -> Maybe Char -> Char -> Float
kernedAdvance fm prev c = case prev of
  Nothing -> fmAdvance fm c
  Just p -> fmAdvance fm c + fmKerning fm p c

-- | The character index whose caret is nearest @x@: from the shaped carets
-- when the text was prepared by a shaping host, which handles clusters and
-- right-to-left runs, and otherwise from the same advances and kerning as
-- 'NanoUI.Draw.pushText', so the caret lands where the glyph to its left was
-- drawn.
textIndexAtX :: FontMetrics -> Text -> Float -> Int
textIndexAtX fm txt x
  | T.null txt = 0
  | Just st <- fmShape fm txt =
      let carets = stCarets st
          n = sizeofPrimArray carets
          nearest !best !bestD !i
            | i >= n = best
            | otherwise =
                let d = abs (indexPrimArray carets i - x)
                 in if d < bestD then nearest i d (i + 1) else nearest best bestD (i + 1)
       in nearest 0 (1 / 0) 0
  | x <= 0 = 0
  | otherwise = go 0 0.0 Nothing txt
  where
    go !i !acc prev t =
      case T.uncons t of
        Nothing -> i
        Just (c, rest) ->
          let adv = kernedAdvance fm prev c
              mid = acc + adv * 0.5
           in if x < mid then i else go (i + 1) (acc + adv) (Just c) rest

-- | Where the caret before character @i@ of @txt@ sits: a shaped caret when
-- the snapshot was prepared for @txt@, else the width of the characters
-- before it.
caretX :: FontMetrics -> Text -> Int -> Float
caretX fm txt i = case fmShape fm txt of
  Just st ->
    let carets = stCarets st
     in if sizeofPrimArray carets == 0 then 0 else indexPrimArray carets (max 0 (min (sizeofPrimArray carets - 1) i))
  Nothing -> lineWidth fm (T.take i txt)

caretXIO :: FontMetrics -> Text -> Int -> IO Float
caretXIO fm txt i = do
  prepared <- prepareFontMetrics fm txt
  pure $! caretX prepared txt i

-- | The horizontal extents covering characters @lo@ to @hi@: one span for
-- left-to-right text, and a span per direction run where a selection crosses
-- right-to-left text.
selectionSpans :: FontMetrics -> Text -> Int -> Int -> [(Float, Float)]
selectionSpans fm txt lo hi
  | hi <= lo = []
  | Just st <- fmShape fm txt =
      let carets = stCarets st
          n = sizeofPrimArray carets - 1
          charSpan i =
            let a = indexPrimArray carets i
                b = indexPrimArray carets (i + 1)
             in (min a b, max a b)
          merge [] = []
          merge [one] = [one]
          merge ((a0, a1) : (b0, b1) : rest)
            | b0 <= a1 + 0.5 && b1 >= a0 - 0.5 = merge ((min a0 b0, max a1 b1) : rest)
            | otherwise = (a0, a1) : merge ((b0, b1) : rest)
       in merge [charSpan i | i <- [max 0 lo .. min n hi - 1]]
  | otherwise = [(caretX fm txt lo, caretX fm txt hi)]

lineWidth :: FontMetrics -> Text -> Float
lineWidth fm line
  | T.null line = 0
  | otherwise =
      case fmShape fm line of
        Just st -> stAdvance st
        Nothing ->
          let !spaceAdv = fmAdvance fm ' '
              !xAdv = fmAdvance fm 'x'
              !mAdv = fmAdvance fm 'M'
           in if spaceAdv == xAdv && xAdv == mAdv && fmKerning fm 'x' 'M' == 0
                then fromIntegral (T.length line) * spaceAdv
                else case T.uncons line of
                  Just (c0, rest) ->
                    fst (T.foldl' step (fmAdvance fm c0, c0) rest)
                  Nothing -> 0
  where
    step (!w, !prev) c = (w + kernedAdvance fm (Just prev) c, c)

measureTextWrappedIO :: (Text -> IO Float) -> FontMetrics -> Text -> Float -> IO (Float, Float)
measureTextWrappedIO lineW fm txt maxW = do
  textLines <- wrapTextLinesIO lineW txt maxW
  ws <- mapM lineW textLines
  let lineH = fmLineHeight fm
  pure $ case textLines of
    [] -> (0, lineH)
    _ -> (min maxW (maximum ws), lineH * fromIntegral (length textLines))

-- | Wrap each paragraph to @maxW@ using the host line measure: whole words
-- first, characters for words (or paragraphs) that cannot fit.
wrapTextLinesIO :: (Text -> IO Float) -> Text -> Float -> IO [Text]
wrapTextLinesIO lineW txt maxW = concat <$> mapM wrapParagraph (T.lines txt)
  where
    wrapParagraph para
      | maxW <= 0 = pure []
      | T.null para = pure [""]
      | otherwise = do
          w <- lineW para
          if w <= maxW
            then pure [para]
            else if T.any (== ' ') para
              then wrapWords (T.words para) []
              else reverse <$> charLines para []
    wrapWords [] acc = pure (reverse acc)
    wrapWords (word : wordsLeft) acc = case acc of
      [] -> startLine word wordsLeft acc
      line : rest -> do
        let candidate = line <> " " <> word
        width <- lineW candidate
        if width <= maxW
          then wrapWords wordsLeft (candidate : rest)
          else startLine word wordsLeft acc
    startLine word wordsLeft acc = do
      width <- lineW word
      if width <= maxW
        then wrapWords wordsLeft (word : acc)
        else do
          broken <- charLines word []
          wrapWords wordsLeft (broken ++ acc)
    charLines chunk acc
      | T.null chunk = pure acc
      | otherwise = do
          (line, rest) <- takeWidth lineW maxW chunk
          if T.null line
            then pure acc
            else charLines rest (line : acc)

-- Always consume at least one character from non-empty text, even when a
-- single glyph exceeds the available width, so wrapping makes progress.
takeWidth :: (Text -> IO Float) -> Float -> Text -> IO (Text, Text)
takeWidth lineW maxW txt
  | T.null txt = pure (txt, T.empty)
  | otherwise = (`T.splitAt` txt) <$> maxFit 1 (T.length txt)
  where
    maxFit lo hi
      | lo >= hi = pure lo
      | otherwise = do
          let mid = (lo + hi + 1) `div` 2
          ok <- (<= maxW) <$> lineW (T.take mid txt)
          if ok then maxFit mid hi else maxFit lo (mid - 1)

truncateTextIO :: (Text -> IO Float) -> Float -> Text -> IO Text
truncateTextIO lineW maxW txt
  | maxW <= 0 = pure ""
  | otherwise = do
      w <- lineW txt
      if w <= maxW
        then pure txt
        else do
          ellW <- lineW "..."
          if maxW <= ellW
            then fst <$> takeWidth lineW maxW txt
            else do
              (fit, _) <- takeWidth lineW (maxW - ellW) txt
              pure (T.dropWhileEnd (== '.') fit <> "...")