module Penny.Cabin.TextFormat ( Lines(Lines, unLines), Words(Words, unWords), CharsPerLine(unCharsPerLine), txtWords, wordWrap, Target(Target, unTarget), Shortest(Shortest, unShortest), shorten) where import qualified Control.Monad.Trans.State as St import qualified Data.Foldable as F import Data.Sequence ((|>), ViewR((:>)), ViewL((:<))) import qualified Data.Sequence as S import qualified Data.Text as X import qualified Data.Traversable as T data Lines = Lines { unLines :: S.Seq Words } deriving Show data Words = Words { unWords :: S.Seq X.Text } deriving Show newtype CharsPerLine = CharsPerLine { unCharsPerLine :: Int } deriving Show -- | Splits a blank-separated text into words. txtWords :: X.Text -> Words txtWords = Words . S.fromList . X.words -- | Wraps a sequence of words into a sequence of lines, where each -- line is no more than a given maximum number of characters long. -- -- If the maximum number of characters per line is less than 1, -- returns a Lines that is empty. -- -- An individual word will be split across multiple lines only if that -- word is too long to fit into a single line. No hyphenation is done; -- the word is simply broken across two lines. wordWrap :: Int -> Words -> Lines wordWrap l (Words wsq) = if l < 1 then Lines (S.empty) else F.foldl f (Lines S.empty) wsq where f (Lines sws) w = let (back, ws) = case S.viewr sws of S.EmptyR -> (S.empty, Words S.empty) (b :> x) -> (b, x) in case addWord l ws w of (Just ws') -> Lines $ back |> ws' Nothing -> if X.length w > l then addPartialWords l (Lines sws) w else Lines (back |> ws |> (Words (S.singleton w))) lenWords :: Words -> Int lenWords (Words s) = case S.length s of 0 -> 0 l -> (F.sum . fmap X.length $ s) + (l - 1) -- | Adds a word to a Words, but only if it will not make the Words -- exceed the given length. addWord :: Int -> Words -> X.Text -> Maybe Words addWord l (Words ws) w = let words' = Words (ws |> w) in if lenWords words' > l then Nothing else Just words' -- | Adds a word to a Words. If the word is too long to fit, breaks it -- and adds the longest portion possible. Returns the new Words, and a -- Text with the part of the word that was not added (if any; if all -- of the word was added, return an empty Text.) addPartialWord :: Int -> Words -> X.Text -> (Words, X.Text) addPartialWord l (Words ws) t = case addWord l (Words ws) t of (Just ws') -> (ws', X.empty) Nothing -> let maxChars = if S.null ws then l else max 0 (l - lenWords (Words ws) - 1) (begin, end) = X.splitAt maxChars t in (Words (if X.null begin then ws else ws |> begin), end) addPartialWords :: Int -> Lines -> X.Text -> Lines addPartialWords l (Lines wsq) t = let (back, ws) = case S.viewr wsq of S.EmptyR -> (S.empty, Words S.empty) (b :> x) -> (b, x) (rw, rt) = addPartialWord l ws t in if X.null rt then Lines (back |> rw) else addPartialWords l (Lines (back |> rw |> Words (S.empty))) rt newtype Target = Target { unTarget :: Int } deriving Show newtype Shortest = Shortest { unShortest :: Int } deriving Show -- | Takes a list of words and shortens it so that it fits in the -- space allotted. You specify the minimum length for each word, x. It -- will shorten the farthest left word first, until it is only x -- characters long; then it will shorten the next word until it is -- only x characters long, etc. This proceeds until all words are just -- x characters long. Then words are shortened to one -- character. Then the leftmost words are deleted as necessary. -- -- Assumes that the words will be printed with a separator, which -- matters when lengths are calculated. shorten :: Shortest -> Target -> Words -> Words shorten (Shortest s) (Target t) wsa@(Words wsq) = let nToRemove = max (lenWords wsa - t) 0 (allWords, _) = shortenUntilOne s nToRemove wsq in stripWordsUntil t (Words allWords) -- | Shorten a word by x characters or until it is y characters long, -- whichever comes first. Returns the word and the number of -- characters removed. shortenUntil :: Int -> Int -> X.Text -> (X.Text, Int) shortenUntil by shortest t = let removable = max (X.length t - shortest) 0 toRemove = min removable (max by 0) prefix = X.length t - toRemove in (X.take prefix t, toRemove) -- | Shortens a word until it is x characters long or by the number of -- characters indicated in the state, whichever is less. Subtracts the -- number of characters removed from the state. shortenSt :: Int -> X.Text -> St.State Int X.Text shortenSt shortest t = do by <- St.get let (r, nRemoved) = shortenUntil by shortest t St.put (by - nRemoved) return r -- | Shortens each word in a list, from left to right, until a -- particular number of characters have been reduced or until each -- word is x characters long, whichever happens first. Returns the new -- list and the number of characters that still need to be reduced. shortenEachInList :: T.Traversable t => Int -- ^ Shortest word length -> Int -- ^ Total number to remove -> t X.Text -> (t X.Text, Int) shortenEachInList shortest by ts = (r, left) where k = T.mapM (shortenSt shortest) ts (r, left) = St.runState k by shortenUntilOne :: T.Traversable t => Int -- ^ Shortest word length to start with -> Int -- ^ Total number of characters to remove -> t X.Text -> (t X.Text, Int) shortenUntilOne shortest by ts = let r@(ts', left) = shortenEachInList shortest by ts in if shortest == 1 || left == 0 then r else shortenUntilOne (pred shortest) left ts' -- | Eliminates words until the length of the words, as indicated by -- lenWords, is less than or equal to the value given. stripWordsUntil :: Int -> Words -> Words stripWordsUntil i wsa@(Words ws) = case S.viewl ws of S.EmptyL -> Words (S.empty) (_ :< rest) -> if lenWords wsa <= (max i 0) then wsa else stripWordsUntil (max i 0) (Words rest) -- -- Testing -- _words :: Words _words = Words . S.fromList . map X.pack $ ws where ws = [ "these", "are", "fragilisticwonderfulgood", "good", "", "x", "xy", "xyza", "longlonglongword" ]