{-# LANGUAGE TemplateHaskell #-} module UI.CardSelector (runCardSelectorUI , getRecents , getRecentsFile , addRecent , runCardsWithOptions) where import Brick import Brick.Widgets.Border import Brick.Widgets.Border.Style import Brick.Widgets.Center import Control.Exception (displayException, try) import Control.Monad (filterM) import Control.Monad.IO.Class import Data.Functor (void) import Data.List (sort) import Data.Random import Lens.Micro.Platform import Parser import Stack (Stack) import System.Environment (lookupEnv) import System.FilePath ((), splitFileName, dropExtension, splitPath, joinPath) import Types import UI.BrickHelpers import UI.FileBrowser (runFileBrowserUI) import UI.Cards (runCardsUI, Card) import qualified Brick.Widgets.List as L import qualified Data.Vector as Vec import qualified Graphics.Vty as V import qualified Stack as S import qualified System.Directory as D import qualified System.IO.Strict as IOS (readFile) type Event = () type Name = () data State = State { _list :: L.List Name String , _exception :: Maybe String , _recents :: Stack FilePath , _gs :: GlobalState } makeLenses ''State app :: App State Event Name app = App { appDraw = drawUI , appChooseCursor = neverShowCursor , appHandleEvent = handleEvent , appStartEvent = return , appAttrMap = const theMap } drawUI :: State -> [Widget Name] drawUI s = [ drawMenu s <=> drawException s ] title :: Widget Name title = withAttr titleAttr $ hCenteredStrWrap "Select a deck of flashcards" drawMenu :: State -> Widget Name drawMenu s = joinBorders $ center $ withBorderStyle unicodeRounded $ border $ hLimitPercent 60 $ title <=> hBorder <=> hCenter (drawList s) drawList :: State -> Widget Name drawList s = vLimit 6 $ L.renderListWithIndex (drawListElement l) True l where l = s ^. list drawListElement :: L.List Name String -> Int -> Bool -> String -> Widget Name drawListElement l i selected = hCenteredStrWrapWithAttr (wAttr1 . wAttr2) where wAttr1 = if selected then withDefAttr selectedAttr else id wAttr2 = if i == length l - 1 then withAttr lastElementAttr else id drawException :: State -> Widget Name drawException s = case s ^. exception of Nothing -> emptyWidget Just exc -> withAttr exceptionAttr $ strWrap exc titleAttr :: AttrName titleAttr = attrName "title" selectedAttr :: AttrName selectedAttr = attrName "selected" lastElementAttr :: AttrName lastElementAttr = attrName "last element" exceptionAttr :: AttrName exceptionAttr = attrName "exception" theMap :: AttrMap theMap = attrMap V.defAttr [ (L.listAttr, V.defAttr) , (selectedAttr, fg V.white `V.withStyle` V.underline) , (titleAttr, fg V.yellow) , (lastElementAttr, fg V.blue) , (exceptionAttr, fg V.red) ] handleEvent :: State -> BrickEvent Name Event -> EventM Name (Next State) handleEvent s@State{_list=l} (VtyEvent e) = case e of V.EvKey (V.KChar 'c') [V.MCtrl] -> halt s V.EvKey V.KEsc [] -> halt s _ -> do l' <- L.handleListEventVi L.handleListEvent e l let s' = (s & list .~ l') in case e of V.EvKey V.KEnter [] -> case L.listSelectedElement l' of Nothing -> continue s' Just (_, "Select file from system") -> suspendAndResume $ runFileBrowser s' Just (i, _) -> do let fp = (s' ^. recents) `S.unsafeElemAt` i fileOrExc <- liftIO (try (readFile fp) :: IO (Either IOError String)) case fileOrExc of Left exc -> continue (s' & exception ?~ displayException exc) Right file -> case parseCards file of Left parseError -> continue (s' & exception ?~ show parseError) Right result -> suspendAndResume $ do s'' <- addRecentInternal s' fp _ <- runCardsWithOptions (s^.gs) result return (s'' & exception .~ Nothing) _ -> continue s' handleEvent l _ = continue l runCardSelectorUI :: GlobalState -> IO () runCardSelectorUI gs = do rs <- getRecents let prettyRecents = shortenFilepaths (S.toList rs) let options = Vec.fromList (prettyRecents ++ ["Select file from system"]) let initialState = State (L.list () options 1) Nothing rs gs _ <- defaultMain app initialState return () getRecents :: IO (Stack FilePath) getRecents = do rf <- getRecentsFile exists <- D.doesFileExist rf if exists then removeDeletedFiles rf else return S.empty removeDeletedFiles :: FilePath -> IO (Stack FilePath) removeDeletedFiles fp = do file <- IOS.readFile fp existing <- S.fromList <$> filterM D.doesFileExist (lines file) writeRecents existing return existing maxRecents :: Int maxRecents = 5 addRecent :: FilePath -> IO () addRecent fp = do rs <- getRecents let rs' = fp `S.insert` rs rs'' = if S.size rs' <= maxRecents then rs' else S.removeLast rs' writeRecents rs'' addRecentInternal :: State -> FilePath -> IO State addRecentInternal s fp = do addRecent fp refreshRecents s writeRecents :: Stack FilePath -> IO () writeRecents stack = do file <- getRecentsFile writeFile file $ unlines (S.toList stack) getRecentsFile :: IO FilePath getRecentsFile = do maybeSnap <- lookupEnv "SNAP_USER_DATA" xdg <- D.getXdgDirectory D.XdgData "hascard" let dir = case maybeSnap of Just path | not (null path) -> path | otherwise -> xdg Nothing -> xdg D.createDirectoryIfMissing True dir return (dir "recents") initLast :: [a] -> ([a], a) initLast [x] = ([], x) initLast (x:xs) = let (xs', y) = initLast xs in (x:xs', y) shortenFilepaths :: [FilePath] -> [FilePath] shortenFilepaths fps = uncurry shortenFilepaths' (unzip (map ((\(pre, fn) -> (pre, dropExtension fn)) . splitFileName) fps)) where shortenFilepaths' prefixes abbreviations = let ds = duplicates abbreviations in if null ds then abbreviations else shortenFilepaths' (flip map (zip [0..] prefixes) ( \(i, pre) -> if i `elem` ds then joinPath (init (splitPath pre)) else pre )) (flip map (zip [0..] abbreviations) ( \(i, abbr) -> if i `elem` ds then last (splitPath (prefixes !! i)) ++ abbr else abbr) ) duplicates :: Eq a => [a] -> [Int] duplicates = sort . map fst . duplicates' 0 [] [] where duplicates' _ _ acc [] = acc duplicates' i seen acc (x:xs) = duplicates' (i+1) ((i, x) : seen) acc' xs where acc' = case (getPairsWithValue x acc, getPairsWithValue x seen) of ([], []) -> acc ([], ys) -> (i, x) : ys ++ acc (_, _) -> (i, x) : acc -- acc' = if getPairsWithValue x seen then (i, x) : acc else acc getPairsWithValue :: Eq a => a -> [(Int, a)] -> [(Int, a)] getPairsWithValue y [] = [] getPairsWithValue y ((i, x):xs) | x == y = (i, x) : getPairsWithValue y xs | otherwise = getPairsWithValue y xs refreshRecents :: State -> IO State refreshRecents s = do rs <- getRecents let prettyRecents = shortenFilepaths (S.toList rs) options = Vec.fromList (prettyRecents ++ ["Select file from system"]) return $ s & recents .~ rs & list .~ L.list () options 1 runFileBrowser :: State -> IO State runFileBrowser s = do result <- runFileBrowserUI maybe (return s) (\(cards, fp) -> addRecentInternal s fp <* runCardsWithOptions (s^.gs) cards) result runCardsWithOptions :: GlobalState -> [Card] -> IO () runCardsWithOptions state cards = let n = length cards in do cards' <- if state^.doShuffle then sampleFrom (state^.mwc) (shuffleN n cards) else return cards void $ maybe (runCardsUI state cards') (\n -> runCardsUI state (take n cards')) (state^.subset)