{-# LANGUAGE OverloadedStrings #-} module TextViewport ( Item(..) , Buffer(..) , WrapStrategy(..) , RenderedLine(..) , RenderedItem(..) , CachedRender(..) , RenderCache(..) , RenderedBuffer , renderBuffer , flatten , modifyItem , updateRenderedItem , Viewport(..) , defaultViewport , scrollUp , scrollDown , visibleLines , lookupPosition ) where import Data.List (minimumBy) import Data.Ord (comparing) import Data.Text (Text) import Data.Vector (Vector) import qualified Data.HashMap.Strict as HM import qualified Data.Text as T import qualified Data.Vector as V import qualified Text.Hyphenation as H import qualified Data.Sequence as Seq import Data.Sequence (Seq) import qualified Data.Foldable as F -------------------------------------------------------------------------------- -- Logical model -------------------------------------------------------------------------------- data WrapStrategy = NoWrap | FixedWidthWrap | HyphenateWrap { hwDict :: H.Hyphenator , hwCache :: HM.HashMap Text [(Text, Text)] } data Item = Item { itemText :: Text , itemWrap :: WrapStrategy } newtype Buffer = Buffer { unBuffer :: Seq Item } modifyItem :: Int -> (Item -> Item) -> Buffer -> Buffer modifyItem ix f (Buffer xs) = Buffer (Seq.adjust' f ix xs) -------------------------------------------------------------------------------- -- Rendering with provenance -------------------------------------------------------------------------------- data RenderedLine = RenderedLine { rlText :: !Text , rlItemIx :: !Int , rlLineIx :: !Int , rlCharStart :: !Int } deriving (Show) type RenderedBuffer = Seq RenderedItem data RenderedItem = RenderedItem { riLines :: !(Vector RenderedLine) } flatten :: RenderedBuffer -> [RenderedLine] flatten = concatMap (F.toList . riLines) . F.toList renderBuffer :: Int -> Buffer -> RenderCache -> (RenderCache, RenderedBuffer) renderBuffer width (Buffer items) (RenderCache cache) = let n = Seq.length items go i (cAcc, rAcc) | i >= n = (RenderCache cAcc, rAcc) | otherwise = let item = Seq.index items i mOld = Seq.index cache i newEntry = renderItem width i item mOld cAcc' = Seq.update i (Just newEntry) cAcc rAcc' = rAcc Seq.|> crRendered newEntry in go (i + 1) (cAcc', rAcc') in go 0 (cache, Seq.empty) renderItem :: Int -> Int -> Item -> Maybe CachedRender -> CachedRender renderItem width itemIx (Item txt strategy) mOld = case mOld of Just old | crWidth old == width , crText old == txt -> old _ -> let linesV = applyStrategy strategy width itemIx txt rendered = RenderedItem linesV in CachedRender { crWidth = width , crStrategy = strategy , crText = txt , crRendered = rendered } data CachedRender = CachedRender { crWidth :: !Int , crStrategy :: !WrapStrategy , crText :: !Text , crRendered :: !RenderedItem } newtype RenderCache = RenderCache { unRenderCache :: Seq (Maybe CachedRender) } -------------------------------------------------------------------------------- -- Wrapping strategies -------------------------------------------------------------------------------- applyStrategy :: WrapStrategy -> Int -> Int -> Text -> Vector RenderedLine applyStrategy NoWrap width itemIx txt = let rawLines = T.splitOn "\n" txt chunks = map (T.take width) rawLines -- crop offsets = scanOffsetsWithNewlines chunks in V.fromList [ RenderedLine { rlText = chunk, rlItemIx = itemIx, rlLineIx = lineIx, rlCharStart = off } | (lineIx, (off, chunk)) <- zip [0..] (zip offsets chunks) ] applyStrategy FixedWidthWrap width itemIx txt = let rawLines = T.splitOn "\n" txt (allChunks, _) = foldl step ([], 0) rawLines step (acc, off0) line = let chunks = chunkFixed width line offsets = scanOffsetsFrom off0 chunks offNext = off0 + T.length line + 1 -- +1 for newline acc' = acc ++ zip offsets chunks in (acc', offNext) in V.fromList [ RenderedLine { rlText = chunk, rlItemIx = itemIx, rlLineIx = lineIx, rlCharStart = off } | (lineIx, (off, chunk)) <- zip [0..] allChunks ] applyStrategy (HyphenateWrap dict cache0) width itemIx txt = let rawLines = T.splitOn "\n" txt -- fold over each physical line, accumulating: -- * all rendered (offset, chunk) pairs -- * updated hyphenation cache (unused for now) -- * running character offset across lines (allChunks, _cache1, _) = foldl wrapOneLine ([], cache0, 0) rawLines in V.fromList [ RenderedLine { rlText = chunk, rlItemIx = itemIx, rlLineIx = lineIx, rlCharStart = off } | (lineIx, (off, chunk)) <- zip [0..] allChunks ] where -- Wrap a single physical line using TeX‑lite hyphenation wrapOneLine :: ([(Int, Text)], HM.HashMap Text [(Text, Text)], Int) -> Text -> ([(Int, Text)], HM.HashMap Text [(Text, Text)], Int) wrapOneLine (acc, cache, off0) line = let chunks = wrapWithHyphenationTeXLite dict width line offsets = scanOffsetsFrom off0 chunks offNext = off0 + T.length line + 1 -- +1 for newline acc' = acc ++ zip offsets chunks in (acc', cache, offNext) -- | Compute running character offsets for a list of chunks. scanOffsetsWithNewlines :: [Text] -> [Int] scanOffsetsWithNewlines = go 0 where go !_ [] = [] go !o (l:ls) = let off = o o' = o + T.length l + 1 -- +1 for newline in off : go o' ls -- | Chunk a single line into fixed-width pieces. chunkFixed :: Int -> Text -> [Text] chunkFixed w t | w <= 0 = [] | T.null t = [""] | otherwise = let (h, rest) = T.splitAt w t in h : if T.null rest then [] else chunkFixed w rest -------------------------------------------------------------------------------- -- Hyphenation-aware wrapping (TeX-lite) -------------------------------------------------------------------------------- -- Compute offsets starting from a base offset scanOffsetsFrom :: Int -> [Text] -> [Int] scanOffsetsFrom start = go start where go !_ [] = [] go !o (t:ts) = o : go (o + T.length t) ts wrapWithHyphenationTeXLite :: H.Hyphenator -> Int -> Text -> [Text] wrapWithHyphenationTeXLite dict width txt = go (T.words txt) where go [] = [] go ws = case lineCandidates dict width ws of [] -> -- Hyphenation failed: fall back to fixed-width chunking breakWordSafe width ws cs -> let (line, rest, _) = minimumBy (comparing (scoreCandidate width)) cs in line : go rest -- | Lossless fallback: treat remaining words as one long text and -- chunk it into width-sized pieces. Never truncates, never drops text. breakWordSafe :: Int -> [Text] -> [Text] breakWordSafe width ws = chunk (T.unwords ws) where chunk t | T.null t = [] | T.length t <= width = [t] | otherwise = let (c, r) = T.splitAt width t in c : chunk r type Candidate = (Text, [Text], Bool) lineCandidates :: H.Hyphenator -> Int -> [Text] -> [Candidate] lineCandidates dict width = go [] [] where go :: [Text] -> [Candidate] -> [Text] -> [Candidate] go _ acc [] = acc go line acc (w:ws) = let space = if null line then "" else " " baseTxt = T.unwords line -- whole word candidate (no hyphen) wholeTxt = baseTxt <> space <> w wholeLen = T.length wholeTxt acc' = if wholeLen <= width && not (T.null wholeTxt) then (wholeTxt, ws, False) : acc else acc -- hyphenation candidates for this word hyphs = hyphenateWord dict w hyphCands = [ (preTxt, suf : ws, True) | (pre, suf) <- hyphs , not (T.null pre) , let preTxt = baseTxt <> space <> pre <> "-" , let preLen = T.length preTxt , preLen <= width ] acc'' = hyphCands ++ acc' in if wholeLen <= width then go (line ++ [w]) acc'' ws else acc'' hyphenateWord :: H.Hyphenator -> Text -> [(Text, Text)] hyphenateWord dict word = let parts = H.hyphenate dict (T.unpack word) in [ ( T.pack (concat (take i parts)) , T.pack (concat (drop i parts)) ) | i <- [1 .. length parts - 1] ] scoreCandidate :: Int -> Candidate -> Int scoreCandidate width (line, _, endsWithHyphen) = let len = T.length line remSpace = max 0 (width - len) badness = remSpace * remSpace * remSpace hyphenPenalty = if endsWithHyphen then 50 else 0 shortPenalty = if len < width `div` 2 then 200 else 0 in badness + hyphenPenalty + shortPenalty -------------------------------------------------------------------------------- -- Incremental re-rendering -------------------------------------------------------------------------------- updateRenderedItem :: Int -> Int -> Buffer -> RenderCache -> Seq RenderedItem -> (RenderCache, Seq RenderedItem) updateRenderedItem width itemIx (Buffer items) (RenderCache cache) rb = let item = Seq.index items itemIx mOld = Seq.index cache itemIx newEntry = renderItem width itemIx item mOld newCache = Seq.update itemIx (Just newEntry) cache newRB = Seq.update itemIx (crRendered newEntry) rb in (RenderCache newCache, newRB) -------------------------------------------------------------------------------- -- Viewport -------------------------------------------------------------------------------- data Viewport = Viewport { vpWidth :: !Int , vpHeight :: !Int , vpOffset :: !Int } deriving (Show) defaultViewport :: Int -> Int -> RenderedBuffer -> Viewport defaultViewport w h rb = let total = length (flatten rb) off = max 0 (total - h) in Viewport w h off scrollUp :: Int -> Viewport -> Viewport scrollUp k vp = vp { vpOffset = max 0 (vpOffset vp - k) } scrollDown :: Int -> Int -> Viewport -> Viewport scrollDown totalLines n vp = let newTop = min (totalLines - vpHeight vp) (vpOffset vp + n) in vp { vpOffset = max 0 newTop } visibleLines :: RenderedBuffer -> Viewport -> [RenderedLine] visibleLines rb vp = let allLines = flatten rb start = vpOffset vp end = start + vpHeight vp in take (vpHeight vp) . drop start $ allLines -------------------------------------------------------------------------------- -- Coordinate lookup -------------------------------------------------------------------------------- lookupPosition :: Int -> Int -> Viewport -> RenderedBuffer -> Maybe (Int, Int) lookupPosition x y vp rb = let allLines = flatten rb idx = vpOffset vp + y in case drop idx allLines of (rl:_) -> let charIx = rlCharStart rl + x in Just (rlItemIx rl, charIx) [] -> Nothing