Xec-0.1.6: Main.hs
module Main where
import Control.Concurrent
import Control.Monad
import Control.Monad.Trans
import Data.Binary hiding(get)
import qualified Data.Binary as B
import qualified Data.ByteString.Lazy as BS
import Data.Char
import Data.Digest.Pure.SHA
import Data.IORef
import Data.List
import Data.Maybe
import Data.Monoid
import Data.Map (Map)
import qualified Data.Map as M
import Data.Ord
import Data.Word
import Graphics.Rendering.Cairo
import Graphics.UI.Gtk
import Graphics.UI.Gtk.Gdk.Events
import System.Directory
import System.Environment
import System.Posix.Process
import System.Time
import System.FilePath.Posix
import Identicon
import Debug.Trace
-- this is a command launcher intented to be used with xmonad.
-- nice replacement for gnome-panel when used with xmobar.
-- Don't do too much things. Shells/scripting languages are there for complex things.
-- ***IMPLEMENTED***
-- features: (refinement needed)
-- * command history depicted as identicon (thus no need to smuggle around with app icons, etc.)
-- * on-the-fly command ambiguous search
-- version alpha: only implements identicon and search
-- ***IDEAS***
-- it's good to have command common string are automatically given an identicon
-- show difference by highlighting and
-- drag'n'drop???
-- one command may have multiple identicon.
-- | Needs configuration in xmonad.hs to always float this window in start-up.
main=do
home_path<-getEnv "HOME"
let histDir =joinPath [home_path,".Xec"]
histFile=joinPath [histDir,"history.dat"]
createDirectoryIfMissing True histDir
initGUI
(query,poll,notify,finalize)<-beginSearch histFile
-- this order is not arbitrary. can you guess the rule?
area <- drawingAreaNew
entry <- entryNew
box <- vBoxNew False 1
boxPackStart box area PackGrow 0
boxPackEnd box entry PackNatural 0
window <- windowNew
set window [containerBorderWidth:=4,containerChild:=box]
windowSetDefaultSize window 250 450
windowSetPosition window WinPosCenter
windowSetTitle window "Xec"
result_local<-newIORef []
waiting <-newIORef False -- Enter -> complete double-Enter -> exec anyway
-- register event handlers
let handlePoll=do
yield
result<-poll
case result of
Just x -> writeIORef result_local x >> widgetQueueDraw area
Nothing -> return ()
return True
handleExec=do -- single: complete
writeIORef waiting False
rs<-readIORef result_local
unless (null rs) $ set entry [entryText:=head rs]
return False
timeoutAddFull handlePoll priorityDefaultIdle 50
onExpose area $ \Expose {} -> do
result<-readIORef result_local
(w,h) <-widgetGetSize area
dw <-widgetGetDrawWindow area
renderWithDrawable dw $ do
translate 0 (fromIntegral h)
drawCommands w result
return True
let newQuery=do
text <-get entry entryText
(_,h)<-widgetGetSize area
query (1+h `div` commandHeight) text
onEditableChanged entry newQuery
onEntryActivate entry $ do
w<-readIORef waiting
if w
then do -- double: execute anyway
text<-get entry entryText
set entry [entryText:=""]
runMaybeT (execute text) >>= maybe (return ()) (\x->notify x >> mainQuit)
else do -- initiate
writeIORef waiting True
timeoutAddFull handleExec priorityDefaultIdle 350
return ()
onDestroy window mainQuit
-- show all widgets and enter main loop
widgetShowAll window
newQuery
mainGUI
-- finalize searcher
finalize
-- | Monad Transformer for Maybe
data MaybeT m a=MaybeT {runMaybeT :: m (Maybe a)}
instance Monad m => Monad (MaybeT m) where
return=MaybeT . return . Just
f >>= g=MaybeT $ runMaybeT f >>= maybe (return Nothing) (runMaybeT . g)
fail _=MaybeT $ return Nothing
instance MonadTrans MaybeT where
lift f=MaybeT $ f >>= return . Just
-- | Fail without an argument.
fail_ :: Monad m => m a
fail_=fail undefined
-- | This can get ultra large. There should be no limit to number of entries.
-- data CommandLog=CommandLog deriving(Show) -- there's currently no need for this
-- | Processed form of commands. Monoid.
data CommandHistory=CommandHistory (Map String CommandStat)
data CommandStat=CommandStat {lastInvoked :: ClockTime, timesInvoked :: Int}
magic :: Word32
magic=3141592653
instance Binary CommandHistory where
put (CommandHistory m)=put magic >> put m
get=do
x<-B.get
if x/=magic then error "Incorrectly formatted data found. Exiting."
else liftM CommandHistory B.get
instance Binary CommandStat where
put (CommandStat x y)=put x >> put y
get=liftM2 CommandStat B.get B.get
instance Binary ClockTime where
put (TOD secs picosecs)=put secs >> put picosecs
get=liftM2 TOD B.get B.get
instance Monoid CommandHistory where
mempty=CommandHistory M.empty
mappend (CommandHistory m0) (CommandHistory m1)=CommandHistory $ M.unionWith merge m0 m1
where merge s0 s1=CommandStat {lastInvoked =max (lastInvoked s0) (lastInvoked s1)
,timesInvoked=timesInvoked s0+timesInvoked s1}
-- | Create CommandHistory of only one given command execution.
histSingleton :: String -> IO CommandHistory
histSingleton com=do
time<-getClockTime
return $ CommandHistory $ M.singleton com $ CommandStat {lastInvoked=time,timesInvoked=1}
-- | Score commands and return the best ones.
histSearch :: Int -> String -> ClockTime -> CommandHistory -> [String]
histSearch n q time (CommandHistory hist)=map fst $ take n $ reverse $ sortBy (comparing snd) pairs
where
pairs=catMaybes $ zipWith4 eval (M.keys hist) score_n score_d score_m
eval k n d m
|m<=0 = Nothing
|otherwise = Just (k,n+m+d)
--
score_n=evalN $ M.elems hist
score_d=evalD time $ M.elems hist
score_m=evalM q $ M.keys hist
-- | Score CommandStats in terms of total number of times of execution.
evalN :: [CommandStat] -> [Double]
evalN=map (log . (+1) . fromIntegral . timesInvoked)
-- | Score CommandStats in terms of relative durations between last invocation.
evalD :: ClockTime -> [CommandStat] -> [Double]
evalD time ss=map f ds
where
f x=fromIntegral (1+minimum ds)/fromIntegral (1+x)
ds=map (getDurationInSeconds time . lastInvoked) ss
-- | Most important. Score Commands in terms of mathing with the query.
-- Commands with 0 point should be removed from the result.
evalM :: String -> [String] -> [Double]
evalM k=map (\x->sum $ map (toI . isPrefixOf k) $ splitRedundant x)
where toI True =1
toI False=0
-- | Returns all possible words by separation.
splitRedundant :: String -> [String]
splitRedundant s=nub $ s:concatMap (flip looseSepBy s) " :_-/"
looseSepBy :: Eq a => a -> [a] -> [[a]]
looseSepBy s xs=map reverse $ filter (not.null) $ aux xs []
where
aux [] t=[t]
aux (x:xs) t
|x==s = t:aux xs []
|x/=s = aux xs (x:t)
-- | Get duration between 2 ClotkTimes in exact seconds.
getDurationInSeconds :: ClockTime -> ClockTime -> Integer
getDurationInSeconds (TOD t1 _) (TOD t0 _)=t1-t0
stringToIdenticon :: String -> Identicon
stringToIdenticon=mapIdenticon . (flip mod numIdenticon) . integerDigest . sha256 . BS.pack . map (fromIntegral.ord)
commandMargin,commandSize,commandHeight :: Int
commandSize =24
commandMargin=1
commandHeight=commandSize+commandMargin*2
drawCommands :: Int -> [String] -> Render ()
drawCommands width rs=zipWithM_ (\ix r->drawCommand width ix r $ ix==0) [0..] rs
drawCommand :: Int -> Int -> String -> Bool -> Render ()
drawCommand w i s compl=do
save
translate 0 $ fromIntegral (i*negate commandHeight)
-- highlight
when compl $ do
setSourceRGBA 1 0.9 0.3 0.5
rectangle 0 (fromIntegral $ negate commandHeight) (fromIntegral w) (fromIntegral commandHeight)
fill
-- separator
setLineWidth 1
setSourceRGB 0.3 0.3 0.35
moveTo 0 0
lineTo (fromIntegral w) 0
stroke
-- identicon
save
translate 0 $ negate $ fromIntegral $ commandSize+commandMargin
scale size size
Identicon.render $ stringToIdenticon s
restore
-- text
selectFontFace "Monospace" FontSlantNormal FontWeightNormal
setFontSize $ 0.65*size
ext<-textExtents s
translate (fromIntegral $ commandSize+commandMargin) (0.5*(textExtentsHeight ext-(fromIntegral commandHeight)))
setSourceRGB 0 0 0
showText s
fill
restore
where size=fromIntegral commandSize
-- | Return absolute path of the executable if it exists.
getExecutable :: String -> MaybeT IO FilePath
getExecutable name=do
ps<-lift getSearchPath
xs<-lift $ mapM (runMaybeT . check) $ reverse ps
MaybeT $ return $! selectMaybe xs
where check p=do
let path=joinPath [p,name]
x<-lift $ doesFileExist path
unless x fail_
perm<-lift $ getPermissions path
unless (executable perm) fail_
return path
-- | execute given command in a separate process.
execute :: String -> MaybeT IO CommandHistory
execute command=do
exe<-getExecutable com
hist<-lift $ histSingleton $ unwords $ com:args
lift $ forkProcess $ executeFile exe False args Nothing
return hist
where (com:args)=words command
-- | Read CommandHistory from file and search asynchronously. This function returns immediately.
-- Returns (query,poll,notify,finialize). Returned actions are thread-safe.
beginSearch :: FilePath -> IO (Int->String->IO (),IO (Maybe [String]),CommandHistory->IO(),IO())
beginSearch path=do
-- read history file strictly
exist<-doesFileExist path
let getHist|exist = BS.readFile path >>= return . decode . BS.reverse . BS.reverse
|otherwise = return mempty
hv<-getHist >>= newIORef
qv<-newEmptyMVar
rv<-newEmptyMVar
let loop x=do
q<-takeMVar qv
maybe (return ()) killThread x
s<-spawn q
loop $ Just s
spawn (n,q)=forkIO $ do
hist<-readIORef hv
time<-getClockTime
putMVar rv (histSearch n q time hist)
forkIO $ loop Nothing
let query n q=putMVar qv (n,q)
poll=tryTakeMVar rv
notify dh=readIORef hv >>= writeIORef hv . mappend dh
finalize=readIORef hv >>= encodeFile path
return (query,poll,notify,finalize)
selectMaybe :: [Maybe a] -> Maybe a
selectMaybe ms=case catMaybes ms of
[] -> Nothing
x:_ -> Just x