{- Bustle.UI: displays charts of D-Bus activity Copyright © 2008–2011 Collabora Ltd. This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -} {-# LANGUAGE ScopedTypeVariables #-} module Bustle.UI ( uiMain ) where import Control.Monad.Reader import Control.Monad.State import Control.Monad.Error import Data.IORef import qualified Data.Set as Set import Data.List (intercalate) import Data.Time import Data.Monoid (mempty) import Text.Printf import Paths_bustle import Bustle.Application.Monad import Bustle.Renderer import Bustle.Types import Bustle.Diagram import Bustle.Gtk (b_windowSetTitlebar, b_headerBarSetSubtitle) import Bustle.Marquee (toString) import Bustle.Util import Bustle.UI.AboutDialog import Bustle.UI.Canvas import Bustle.UI.DetailsView import Bustle.UI.FilterDialog import Bustle.UI.OpenTwoDialog (setupOpenTwoDialog) import Bustle.UI.Recorder import Bustle.UI.Util (displayError) import Bustle.StatisticsPane import Bustle.Translation (__) import Bustle.Loader import qualified Control.Exception as C import System.Glib.GError (GError(..), failOnGError) import Graphics.UI.Gtk import Graphics.Rendering.Cairo (withPDFSurface, renderWith) import System.FilePath ( splitFileName, takeFileName, takeDirectory , dropExtension, dropTrailingPathSeparator , (), (<.>) ) import System.GIO.File.File (fileFromParseName, fileMove, FileCopyFlags(..)) type B a = Bustle BConfig BState a data LogDetails = RecordedLog FilePath | SingleLog FilePath | TwoLogs FilePath FilePath data Page = InstructionsPage | PleaseHoldPage | CanvasPage deriving (Enum) data WindowInfo = WindowInfo { wiWindow :: Window , wiHeaderBar :: Widget -- TODO , wiSave :: Button , wiExport :: Button , wiViewStatistics :: CheckMenuItem , wiFilterNames :: MenuItem , wiNotebook :: Notebook , wiStatsBook :: Notebook , wiStatsPane :: StatsPane , wiContentVPaned :: VPaned , wiCanvas :: Canvas (Detailed Message) , wiDetailsView :: DetailsView , wiLogDetails :: IORef (Maybe LogDetails) } data BConfig = BConfig { debugEnabled :: Bool , methodIcon :: Maybe Pixbuf , signalIcon :: Maybe Pixbuf } data BState = BState { windows :: Int , initialWindow :: Maybe WindowInfo } modifyWindows :: (Int -> Int) -> B () modifyWindows f = modify $ \s -> s { windows = f (windows s) } incWindows :: B () incWindows = modifyWindows (+1) decWindows :: B Int decWindows = modifyWindows (subtract 1) >> gets windows uiMain :: IO () uiMain = failOnGError $ do args <- initGUI -- FIXME: get a real option parser let debug = any isDebug args [method, signal] <- mapM loadPixbuf ["dfeet-method.png", "dfeet-signal.png"] let config = BConfig { debugEnabled = debug , methodIcon = method , signalIcon = signal } initialState = BState { windows = 0 , initialWindow = Nothing } runB config initialState $ mainB (filter (not . isDebug) args) where isDebug = (== "--debug") mainB :: [String] -> B () mainB args = do case args of ["--pair", sessionLogFile, systemLogFile] -> loadLog (TwoLogs sessionLogFile systemLogFile) _ -> mapM_ (loadLog . SingleLog) args -- If no windows are open (because none of the arguments, if any, were loaded -- successfully) create an empty window n <- gets windows when (n == 0) createInitialWindow io mainGUI createInitialWindow :: B () createInitialWindow = do misc <- emptyWindow modify $ \s -> s { initialWindow = Just misc } consumeInitialWindow :: B WindowInfo consumeInitialWindow = do x <- gets initialWindow case x of Nothing -> emptyWindow Just windowInfo -> do modify $ \s -> s { initialWindow = Nothing } return windowInfo loadInInitialWindow :: LogDetails -> B () loadInInitialWindow = loadLogWith consumeInitialWindow loadLog :: LogDetails -> B () loadLog = loadLogWith emptyWindow openLog :: MonadIO io => LogDetails -> ErrorT LoadError io ( ([String], [DetailedEvent]) , ([String], [DetailedEvent]) ) openLog (RecordedLog filepath) = do result <- readLog filepath return (result, ([], [])) openLog (SingleLog filepath) = do result <- readLog filepath return (result, ([], [])) openLog (TwoLogs session system) = do sessionResult <- readLog session systemResult <- readLog system return (sessionResult, systemResult) loadLogWith :: B WindowInfo -- ^ action returning a window to load the log(s) in -> LogDetails -> B () loadLogWith getWindow logDetails = do ret <- runErrorT $ do ((sessionWarnings, sessionMessages), (systemWarnings, systemMessages)) <- openLog logDetails -- FIXME: pass the log file name into the renderer let rr = process sessionMessages systemMessages io $ mapM warn $ sessionWarnings ++ systemWarnings ++ rrWarnings rr windowInfo <- lift getWindow lift $ displayLog windowInfo logDetails sessionMessages systemMessages rr case ret of Left (LoadError f e) -> io $ displayError Nothing (printf (__ "Could not read '%s'") f) (Just e) Right () -> return () startRecording :: B () startRecording = do wi <- consumeInitialWindow zt <- io $ getZonedTime -- I hate time manipulation let yyyy_mm_dd_hh_mm_ss = takeWhile (/= '.') (show zt) cacheDir <- io $ getCacheDir let filename = cacheDir yyyy_mm_dd_hh_mm_ss <.> "bustle" setPage wi PleaseHoldPage let mwindow = Just (wiWindow wi) progress = aChallengerAppears wi finished = finishedRecording wi filename embedIO $ \r -> recorderRun filename mwindow progress (\p -> makeCallback (finished p) r) aChallengerAppears :: WindowInfo -> RendererResult a -> IO () aChallengerAppears wi rr = do updateDisplayedLog wi rr canvasScrollToBottom (wiCanvas wi) setPage wi CanvasPage onMenuItemActivate :: MenuItemClass menuItem => menuItem -> IO () -> IO (ConnectId menuItem) onMenuItemActivate mi act = on mi menuItemActivate act finishedRecording :: WindowInfo -> FilePath -> Bool -> B () finishedRecording wi tempFilePath producedOutput = do if producedOutput then do -- TODO: There is a noticable lag when reloading big files. It would be -- nice to either make the loading faster, or eliminate the reload. loadLogWith (return wi) (RecordedLog tempFilePath) let saveItem = wiSave wi io $ do widgetSetSensitivity saveItem True saveItem `on` buttonActivated $ showSaveDialog wi (return ()) return () else do setPage wi InstructionsPage modify $ \s -> s { initialWindow = Just wi } updateDisplayedLog wi (mempty :: RendererResult ()) showSaveDialog :: WindowInfo -> IO () -> IO () showSaveDialog wi savedCb = do Just (RecordedLog tempFilePath) <- readIORef (wiLogDetails wi) let mwindow = Just (wiWindow wi) tempFileName = takeFileName tempFilePath recorderChooseFile tempFileName mwindow $ \newFilePath -> do let tempFile = fileFromParseName tempFilePath let newFile = fileFromParseName newFilePath C.catch (fileMove tempFile newFile [FileCopyOverwrite] Nothing Nothing) $ \(GError _ _ msg) -> do d <- messageDialogNew mwindow [DialogModal] MessageError ButtonsOk (__ "Couldn't save log") let secondary :: String secondary = printf (__ "Error: %s\n\n\ \You might want to manually recover the log from the temporary file at\n\ \%s") (toString msg) tempFilePath messageDialogSetSecondaryMarkup d secondary widgetShowAll d d `after` response $ \_ -> do widgetDestroy d return () widgetSetSensitivity (wiSave wi) False wiSetLogDetails wi (SingleLog newFilePath) savedCb -- | Show a confirmation dialog if the log is unsaved. Suitable for use as a -- 'delete-event' handler. promptToSave :: MonadIO io => WindowInfo -> io Bool -- ^ True if we showed a prompt; False if we're -- happy to quit promptToSave wi = io $ do mdetails <- readIORef (wiLogDetails wi) case mdetails of Just (RecordedLog tempFilePath) -> do let tempFileName = takeFileName tempFilePath title = printf (__ "Save log '%s' before closing?") tempFileName :: String prompt <- messageDialogNew (Just (wiWindow wi)) [DialogModal] MessageWarning ButtonsNone title messageDialogSetSecondaryText prompt (__ "If you don't save, this log will be lost forever.") dialogAddButton prompt (__ "Close _Without Saving") ResponseClose dialogAddButton prompt stockCancel ResponseCancel dialogAddButton prompt stockSave ResponseYes widgetShowAll prompt prompt `after` response $ \resp -> do let closeUp = widgetDestroy (wiWindow wi) case resp of ResponseYes -> showSaveDialog wi closeUp ResponseClose -> closeUp _ -> return () widgetDestroy prompt return True _ -> return False maybeQuit :: B () maybeQuit = do n <- decWindows when (n == 0) (io mainQuit) emptyWindow :: B WindowInfo emptyWindow = do builder <- io builderNew io $ builderAddFromFile builder =<< getDataFileName "data/bustle.ui" -- Grab a bunch of widgets. Surely there must be a better way to do this? let getW cast name = io $ builderGetObject builder cast name window <- getW castToWindow "diagramWindow" header <- getW castToWidget "header" io $ b_windowSetTitlebar window header [openItem, openTwoItem] <- mapM (getW castToMenuItem) ["open", "openTwo"] [headerNew, headerSave, headerExport] <- mapM (getW castToButton) ["headerNew", "headerSave", "headerExport"] viewStatistics <- getW castToCheckMenuItem "statistics" filterNames <- getW castToMenuItem "filter" aboutItem <- getW castToMenuItem "about" [newButton, openButton] <- mapM (getW castToButton) ["newButton", "openButton"] [nb, statsBook] <- mapM (getW castToNotebook) ["diagramOrNot", "statsBook"] contentVPaned <- getW castToVPaned "contentVPaned" -- Open two logs dialog openTwoDialog <- embedIO $ \r -> setupOpenTwoDialog window $ \f1 f2 -> makeCallback (loadInInitialWindow (TwoLogs f1 f2)) r -- Set up the window itself embedIO $ (window `on` objectDestroy) . makeCallback maybeQuit -- File menu and related buttons embedIO $ \r -> do let new = makeCallback startRecording r forM [headerNew, newButton] $ \button -> button `on` buttonActivated $ new let open = makeCallback openDialogue r onMenuItemActivate openItem open openButton `on` buttonActivated $ open onMenuItemActivate openTwoItem $ widgetShowAll openTwoDialog -- TODO: really this wants to live in the application menu, but that entails binding GApplication, -- GtkApplication, GMenu, GActionMap, GActionEntry, ... -- -- Similarly, the drop-down menus would look better as popovers. But here we are. io $ onMenuItemActivate aboutItem $ showAboutDialog window m <- asks methodIcon s <- asks signalIcon statsPane <- io $ statsPaneNew builder m s details <- io $ detailsViewNew io $ do let top = detailsViewGetTop details panedPack2 contentVPaned top False False -- Hide the details by default; they'll be shown when the user selects a -- message. widgetHide top -- The stats start off hidden. io $ widgetHide statsBook showBounds <- asks debugEnabled canvas <- io $ canvasNew builder showBounds (updateDetailsView details) logDetailsRef <- io $ newIORef Nothing let windowInfo = WindowInfo { wiWindow = window , wiHeaderBar = header , wiSave = headerSave , wiExport = headerExport , wiViewStatistics = viewStatistics , wiFilterNames = filterNames , wiNotebook = nb , wiStatsBook = statsBook , wiStatsPane = statsPane , wiContentVPaned = contentVPaned , wiCanvas = canvas , wiDetailsView = details , wiLogDetails = logDetailsRef } io $ window `on` deleteEvent $ promptToSave windowInfo incWindows io $ widgetShow window return windowInfo updateDetailsView :: DetailsView -> Maybe (Detailed Message) -> IO () updateDetailsView detailsView newMessage = do case newMessage of Nothing -> do widgetHide $ detailsViewGetTop detailsView Just m -> do detailsViewUpdate detailsView m widgetShow $ detailsViewGetTop detailsView updateDisplayedLog :: MonadIO io => WindowInfo -> RendererResult a -> io () updateDisplayedLog wi rr = io $ do let shapes = rrShapes rr regions = rrRegions rr canvas = wiCanvas wi (windowWidth, _) <- windowGetSize (wiWindow wi) canvasSetShapes canvas shapes regions (rrCentreOffset rr) windowWidth splitFileName_ :: String -> (String, String) splitFileName_ s = (dropTrailingPathSeparator d, f) where (d, f) = splitFileName s logWindowTitle :: LogDetails -> (String, Maybe String) logWindowTitle (RecordedLog filepath) = ("*" ++ takeFileName filepath, Nothing) logWindowTitle (SingleLog filepath) = (name, Just directory) where (directory, name) = splitFileName_ filepath logWindowTitle (TwoLogs sessionPath systemPath) = -- TODO: this looks terrible, need a custom widget (sessionName ++ " & " ++ systemName, Just $ if sessionDirectory == systemDirectory then sessionDirectory else sessionDirectory ++ " & " ++ systemDirectory) where (sessionDirectory, sessionName) = splitFileName_ sessionPath (systemDirectory, systemName ) = splitFileName_ systemPath logTitle :: LogDetails -> String logTitle (RecordedLog filepath) = dropExtension $ takeFileName filepath logTitle (SingleLog filepath) = dropExtension $ takeFileName filepath logTitle (TwoLogs sessionPath systemPath) = intercalate " & " . map (dropExtension . takeFileName) $ [sessionPath, systemPath] wiSetLogDetails :: WindowInfo -> LogDetails -> IO () wiSetLogDetails wi logDetails = do writeIORef (wiLogDetails wi) (Just logDetails) let (title, subtitle) = logWindowTitle logDetails (wiWindow wi) `set` [ windowTitle := title ] b_headerBarSetSubtitle (wiHeaderBar wi) subtitle setPage :: MonadIO io => WindowInfo -> Page -> io () setPage wi page = io $ notebookSetCurrentPage (wiNotebook wi) (fromEnum page) displayLog :: WindowInfo -> LogDetails -> Log -> Log -> RendererResult Participants -> B () displayLog wi@(WindowInfo { wiWindow = window , wiExport = exportItem , wiViewStatistics = viewStatistics , wiFilterNames = filterNames , wiCanvas = canvas , wiStatsBook = statsBook , wiStatsPane = statsPane }) logDetails sessionMessages systemMessages rr = do io $ do wiSetLogDetails wi logDetails hiddenRef <- newIORef Set.empty updateDisplayedLog wi rr widgetSetSensitivity exportItem True exportItem `on` buttonActivated $ do shapes <- canvasGetShapes canvas saveToPDFDialogue wi shapes setPage wi CanvasPage canvasFocus canvas -- FIXME: this currently shows stats for all messages, not post-filtered messages statsPaneSetMessages statsPane sessionMessages systemMessages widgetSetSensitivity viewStatistics True viewStatistics `on` checkMenuItemToggled $ do active <- checkMenuItemGetActive viewStatistics if active then widgetShow statsBook else widgetHide statsBook widgetSetSensitivity filterNames True onMenuItemActivate filterNames $ do hidden <- readIORef hiddenRef hidden' <- runFilterDialog window (sessionParticipants $ rrApplications rr) hidden writeIORef hiddenRef hidden' let rr' = processWithFilters (sessionMessages, hidden') (systemMessages, Set.empty) updateDisplayedLog wi rr' return () loadPixbuf :: FilePath -> IO (Maybe Pixbuf) loadPixbuf filename = do iconName <- getDataFileName $ "data/" ++ filename C.catch (fmap Just (pixbufNewFromFile iconName)) (\(GError _ _ msg) -> warn (toString msg) >> return Nothing) openDialogue :: B () openDialogue = embedIO $ \r -> do chooser <- fileChooserDialogNew Nothing Nothing FileChooserActionOpen [ ("gtk-cancel", ResponseCancel) , ("gtk-open", ResponseAccept) ] chooser `set` [ fileChooserLocalOnly := True ] chooser `after` response $ \resp -> do when (resp == ResponseAccept) $ do Just fn <- fileChooserGetFilename chooser makeCallback (loadInInitialWindow (SingleLog fn)) r widgetDestroy chooser widgetShowAll chooser saveToPDFDialogue :: WindowInfo -> Diagram -> IO () saveToPDFDialogue wi shapes = do let parent = Just (wiWindow wi) chooser <- fileChooserDialogNew Nothing parent FileChooserActionSave [ ("gtk-cancel", ResponseCancel) , ("gtk-save", ResponseAccept) ] chooser `set` [ windowModal := True , fileChooserLocalOnly := True , fileChooserDoOverwriteConfirmation := True ] Just logDetails <- readIORef $ wiLogDetails wi let filename = logTitle logDetails <.> "pdf" fileChooserSetCurrentName chooser filename -- If the currently-loaded log has a meaningful directory, suggest that as -- the default. let mdirectory = case logDetails of RecordedLog _ -> Nothing SingleLog p -> Just $ takeDirectory p TwoLogs p _ -> Just $ takeDirectory p maybeM mdirectory $ fileChooserSetCurrentFolder chooser chooser `after` response $ \resp -> do when (resp == ResponseAccept) $ do Just fn <- io $ fileChooserGetFilename chooser let (width, height) = diagramDimensions shapes withPDFSurface fn width height $ \surface -> renderWith surface $ drawDiagram False shapes widgetDestroy chooser widgetShowAll chooser -- vim: sw=2 sts=2