module Fcd ( run , lcs , sortCandidates ) where import Control.Monad import Control.Concurrent.MVar import Data.Array import Data.List import qualified Data.Text as T import qualified Data.Text.IO as T.IO import System.Directory import System.Posix.Signals import System.Environment (getArgs) import Graphics.Vty.Widgets.List import Graphics.Vty.Widgets.Text import Graphics.Vty.Widgets.Edit import Graphics.Vty.Widgets.Box import Graphics.Vty.Attributes import Graphics.Vty.Widgets.Core import Graphics.Vty.Widgets.EventLoop import Graphics.Vty.LLInput import Paths_fcd (version) import Data.Version data Command = Add | List | PrintVersion | Help | Select | Delete deriving (Eq, Show) data Args = Args { command :: Command, name :: String, shortcut :: String, description :: String, execute :: [String] -> IO ()} availableCommands :: [Args] availableCommands = [ Args { command = Add, name = "add", shortcut = "a", description = "Add new bookmarks. If no additional parameter is provided, bookmark the current directory.", execute = addBookmarks } , Args { command = List, name = "list", shortcut = "l", description = "List the available bookmarks.", execute = const listBookmarks} , Args { command = PrintVersion, name = "version", shortcut = "v", description = "Print version number.", execute = const printVersion} , Args { command = Help, name = "help", shortcut = "h", description = "Print this help.", execute = const printHelp} , Args { command = Select, name = "select", shortcut = "s", description = "Select a bookmark. This is equivalent to calling fcd without arguments.", execute = selectBookmark } , Args { command = Delete, name = "delete", shortcut = "d", description = "Delete a bookmark.", execute = deleteBookmark } ] -- |Main entry point for the program run :: IO () run = do args <- getArgs let (cmdName:opts) = if null args then ["s"] else args case parseCommand cmdName of Nothing -> error $ "Unrecognized command: " ++ cmdName ++ ".\nAvailable commands are: " ++ commandList Just cmd -> execute cmd opts -- |Given a command name or a short-hand version of a command name, returns the -- corresponding command parseCommand :: String -> Maybe Args parseCommand cmd = find (\arg -> name arg == cmd || shortcut arg == cmd) availableCommands -- |Add each path in the provided list to the bookmarks file. addBookmarks :: [String] -> IO () addBookmarks [] = addBookmarks ["."] addBookmarks xs = do allBookmarks <- readBookmarks mapM_ (addBookmark allBookmarks . T.pack) xs -- |Print to stdout the list of bookmarks. listBookmarks :: IO () listBookmarks = liftM (T.unpack . T.intercalate (T.pack "\n")) readBookmarks >>= putStrLn -- |Print to stdout the version number. printVersion :: IO () printVersion = putStrLn $ "fcd version " ++ showVersion version -- |Print to stdout a short help about the program. printHelp :: IO () printHelp = do printVersion putStrLn "Usage: fcd [select [PATH]] | fcd add [PATH1 [...]] | fcd delete [PATH] | fcd list | fcd help | ..." putStrLn "" putStrLn "Available commands:" forM_ availableCommands $ \cmd -> putStrLn $ name cmd ++ " (" ++ shortcut cmd ++ ") -- " ++ description cmd putStrLn "" putStrLn "Each command can be abbreviated to its first letter." putStrLn "When in prompt mode, use C-h and C-l to navigate the result list." -- |Prompt the user to select a bookmark, then write the selected bookmark in -- the bookmark file. selectBookmark :: [String] -> IO () selectBookmark opts = displayPrompt (T.pack $ unwords opts) >>= writeResult -- |Prompt the user to select a bookmark, then delete the selected bookmark from -- the bookmark file. deleteBookmark :: [String] -> IO () deleteBookmark opts = do toDelete <- displayPrompt $ T.pack $ unwords opts path <- bookmarkFile bookmarks <- liftM T.lines (T.IO.readFile path) let filtered = filter (/= toDelete) bookmarks T.IO.writeFile path $ T.concat $ map (flip T.append $ T.pack "\n") filtered -- |Return the path to the bookmark file. bookmarkFile :: IO String bookmarkFile = fmap (++ "/.fcdbookmarks") getHomeDirectory -- |Return the path to the result file. resultFile :: IO String resultFile = fmap (++ "/.fcdresult") getHomeDirectory -- |Return the list of the available commands (list, add, etc.) commandList :: String commandList = intercalate ", " $ map name availableCommands -- |Add a bookmark (a directory path) to the bookmark file. -- If the bookmark already exists, then do nothing. addBookmark :: [T.Text] -> T.Text -> IO () addBookmark allBookmarks bookmark = do path <- bookmarkFile bookmarkClean <- canonicalizePath $ T.unpack bookmark unless (T.pack bookmarkClean `elem` allBookmarks) $ appendFile path (bookmarkClean ++ "\n") -- |Prompt the user to select a bookmark. The given parameter is used to -- prepopulate the prompt. Return the user selection. displayPrompt :: T.Text -> IO T.Text displayPrompt prefill = do result <- newMVar $ T.pack "." inputLine <- editWidget setEditText inputLine prefill candidates <- readBookmarks candidatesList <- newTextList def_attr [] 1 box <- vBox inputLine candidatesList mainFocusGroup <- newFocusGroup mainFocusGroup `onKeyPressed` \_ key modifiers -> if key == KASCII 'c' && modifiers == [MCtrl] then raiseSignal sigINT >> return True else if key == KASCII 'd' && modifiers == [MCtrl] then raiseSignal sigKILL >> return True else if key == KASCII 'h' && modifiers == [MCtrl] then moveSelectionDown candidatesList >> return True else if key == KASCII 'l' && modifiers == [MCtrl] then moveSelectionUp candidatesList >> return True else return False _ <- addToFocusGroup mainFocusGroup box collection <- newCollection _ <- addToCollection collection box mainFocusGroup inputLine `onChange` \inputText -> updateCandidates inputText candidates candidatesList inputLine `onActivate` \_ -> do choice <- getSelected candidatesList let selection = case choice of Nothing -> T.pack "." Just (_pos, (entry, _) ) -> entry modifyMVar_ result (const $ return selection) shutdownUi updateCandidates prefill candidates candidatesList runUi collection defaultContext takeMVar result moveSelectionDown :: Widget (List T.Text FormattedText) -> IO () moveSelectionDown listWidget = do selected <- getSelected listWidget case selected of Nothing -> return () Just (pos, _) -> unless (pos == 0) (setSelected listWidget (pos - 1)) moveSelectionUp :: Widget (List T.Text FormattedText) -> IO () moveSelectionUp listWidget = do selected <- getSelected listWidget case selected of Nothing -> return () Just (pos, _) -> setSelected listWidget (pos + 1) -- vty-ui takes care of not going out of bounds -- |Update a list widget by sorting the candidates list according to a reference updateCandidates :: T.Text -- a reference (user provided input) -> [T.Text] -- a list of candidates -> Widget (List T.Text FormattedText) -- the list widget to update -> IO () updateCandidates inputText candidates candidatesList = do let sortedCandidates = sortCandidates (map T.unpack candidates) (T.unpack inputText) displayedCandidates = map T.pack sortedCandidates setCandidates displayedCandidates candidatesList -- |Write the selected bookmark to the result file writeResult :: T.Text -> IO () writeResult selection = resultFile >>= flip T.IO.writeFile selection -- |Retrieve the bookmark list from disk readBookmarks :: IO [T.Text] readBookmarks = do path <- bookmarkFile exist <- doesFileExist path if exist then liftM T.lines $ T.IO.readFile path else return [] setCandidates :: [T.Text] -> Widget (List T.Text FormattedText) -> IO () setCandidates candidates list = do clearList list forM_ candidates (\el -> addToList list el =<< plainText el) -- |Compute the length of the longest common subsequence of two lists. -- This is a simple implementation with memoization that uses quadratic space -- (O(n*m) where n and m are the length of the inputs). -- Note: a possible optimization would be to cache the memoized matrix as a lot -- of it is still relevant when a new character is added. lcs :: (Eq a) => [a] -> [a] -> Int lcs xs ys = memoized ! (n,m) where memoized = array ((0,0),(n,m)) [((i,j), lcs' i j) | i <- [0..n], j <- [0..m] ] n = length xs m = length ys as = listArray (1, n) xs bs = listArray (1, m) ys lcs' _ 0 = 0 lcs' 0 _ = 0 lcs' u v = if as ! u == bs ! v then memoized ! (u - 1, v - 1) + 1 else max (memoized ! (u - 1, v)) (memoized ! (u, v - 1)) sortCandidates :: (Eq a) => [[a]] -> [a] -> [[a]] sortCandidates candidates reference = sortBy comparator candidates where comparator x y = let distRefToX = distance x reference distRefToY = distance y reference distance a b = - lcs a b in compare distRefToX distRefToY