From 731edfef3f416fc763a7fbbd26d482b77b2b4068 Mon Sep 17 00:00:00 2001 From: Hannes Schulze Date: Sun, 22 Jun 2025 12:54:49 +0200 Subject: refactor --- app/Foobar/Application.hs | 39 +++------ app/Foobar/Models/Config.hs | 99 +++++++++++---------- app/Foobar/Services/Coalescing.hs | 46 ++++++++++ app/Foobar/Services/Config.hs | 178 +++++++++++++++++++++++--------------- app/foobar.cabal | 3 +- 5 files changed, 219 insertions(+), 146 deletions(-) create mode 100644 app/Foobar/Services/Coalescing.hs diff --git a/app/Foobar/Application.hs b/app/Foobar/Application.hs index 94c8182..8034d11 100644 --- a/app/Foobar/Application.hs +++ b/app/Foobar/Application.hs @@ -7,56 +7,45 @@ module Foobar.Application import Data.Maybe (fromJust) import Data.GI.Base -import Data.Text import GHC.Generics (Generic) -import Foobar.Services.Config (initConfiguration, loadConfiguration, observeConfiguration) import Declarative.Gtk +import Foobar.Services.Config +import Foobar.Models.Config import qualified GI.Gtk as Gtk import qualified GI.Gdk as Gdk import qualified GI.Gio as Gio import qualified GI.Foobar as FB -mkApplication :: IO Gtk.Application -mkApplication = new Gtk.Application [ #applicationId := "com.github.hannesschulze.foobar-hs" - , On #activate (onActivate ?self) ] - newtype AppState - = AppState { stateText :: Text } + = AppState { config :: Config } deriving (Generic) -data Message = MsgQuit | MsgDoSomething +data Message = MsgConfigChanged -initialState :: AppState -initialState = AppState { stateText = "Do something" } +mkApplication :: IO Gtk.Application +mkApplication = new Gtk.Application [ #applicationId := "com.github.hannesschulze.foobar-hs" + , On #activate (onActivate ?self) ] appView :: View AppState Message Gtk.Widget -appView state emit = widget DeclarativeBox [ #children := children - , #orientation := Gtk.OrientationVertical - , #spacing := 12] - where children = [ widget Gtk.Button [ #label :<~ state.stateText - , On #clicked (emit MsgDoSomething) ] - , widget Gtk.Button [ #label := "Quit" - , On #clicked (emit MsgQuit) ] ] +appView state _ = widget Gtk.Label [ #label :<~ lbl ] + where lbl = fmap ("Stylesheet: " <>) state.config.general.stylesheet appHandler :: Handler Message AppState -appHandler MsgQuit _ = pure () -appHandler MsgDoSomething s = s.stateText <~ "Did something!" +appHandler MsgConfigChanged s = do newConfig <- loadConfig + s.config <~ newConfig onActivate :: Gtk.Application -> IO () onActivate app = do - conf <- initConfiguration - putStrLn $ "Initial config: " ++ show conf - observeConfiguration $ do - conf' <- loadConfiguration - putStrLn $ "Updated config: " ++ show conf' + initialState <- AppState <$> initConfig provider <- new Gtk.CssProvider [] provider.loadFromResource "/foobar/styles/default.css" disp <- Gdk.displayGetDefault Gtk.styleContextAddProviderForDisplay (fromJust disp) provider (fromIntegral Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) - view <- mvh initialState appView appHandler + (view, emit, _) <- fullMVH initialState appView appHandler + observeConfig (emit MsgConfigChanged) window <- new Gtk.ApplicationWindow [ #application := app , #title := "Test" , #child := view ] diff --git a/app/Foobar/Models/Config.hs b/app/Foobar/Models/Config.hs index a21049a..7df0936 100644 --- a/app/Foobar/Models/Config.hs +++ b/app/Foobar/Models/Config.hs @@ -18,11 +18,11 @@ module Foobar.Models.Config , Action(..) , -- * Panel Configuration PanelItem(..) - , PanelItemConfiguration(..) - , PanelIconConfiguration(..) - , PanelClockConfiguration(..) - , PanelWorkspacesConfiguration(..) - , PanelStatusConfiguration(..) + , PanelItemConfig(..) + , PanelIconConfig(..) + , PanelClockConfig(..) + , PanelWorkspacesConfig(..) + , PanelStatusConfig(..) , PanelStatusItem(..) , PanelItemPosition(..) , -- * Control Center Configuration @@ -40,14 +40,14 @@ data Config , controlCenter :: !ControlCenterConfig -- ^ The control center configuration. , notifications :: !NotificationConfig -- ^ The notification configuration. } - deriving (Show) + deriving (Show, Eq) -- | General application settings not specific to a single component. data GeneralConfig = GeneralConfig { stylesheet :: !Text -- ^ URI to the CSS stylesheet to use -- this may be a resource that's bundled -- with Foobar or a "file:" URI. } - deriving (Show) + deriving (Show, Eq) -- | Configuration for the panel shown at the edge of a screen. data PanelConfig @@ -60,7 +60,7 @@ data PanelConfig , multiMonitor :: !Bool -- ^ Flag to enable the panel on all monitors. , items :: ![PanelItem] -- ^ Items configured to be displayed in the panel. } - deriving (Show) + deriving (Show, Eq) -- | Configuration for the application launcher. data LauncherConfig @@ -68,7 +68,7 @@ data LauncherConfig , position :: !Int -- ^ Offset from the top of the screen. , maxHeight :: !Int -- ^ Maximum allowed height for the launcher before scrolling is enabled. } - deriving (Show) + deriving (Show, Eq) -- | Configuration for the control center. data ControlCenterConfig @@ -86,7 +86,7 @@ data ControlCenterConfig , rows :: ![ControlCenterRow] -- ^ Ordered list of rows to display in the controls -- section. } - deriving (Show) + deriving (Show, Eq) -- | Configuration for notifications and the notification area shown in the corner of the screen. data NotificationConfig @@ -97,7 +97,7 @@ data NotificationConfig -- negative). , timeFormat :: !Text -- ^ The time format string as used by g_date_time_format. } - deriving (Show) + deriving (Show, Eq) -- | An edge of the screen. data ScreenEdge @@ -122,55 +122,54 @@ data Action -- | Configuration for an item in the panel, including a general item configuration and an item-specific configuration. data PanelItem - = PanelItemIcon !PanelItemConfiguration !PanelIconConfiguration - | PanelItemClock !PanelItemConfiguration !PanelClockConfiguration - | PanelItemWorkspaces !PanelItemConfiguration !PanelWorkspacesConfiguration - | PanelItemStatus !PanelItemConfiguration !PanelStatusConfiguration - deriving (Show) + = PanelItemIcon !PanelItemConfig !PanelIconConfig + | PanelItemClock !PanelItemConfig !PanelClockConfig + | PanelItemWorkspaces !PanelItemConfig !PanelWorkspacesConfig + | PanelItemStatus !PanelItemConfig !PanelStatusConfig + deriving (Show, Eq) -- | General configuration for items in the panel. -data PanelItemConfiguration - = PanelItemConfiguration { name :: !Text -- ^ Name of the panel item (this is derived from the - -- section name, which has the form "panel.[name]"). - , position :: !PanelItemPosition -- ^ Position where the item should be placed item - -- within the panel. - } - deriving (Show) +data PanelItemConfig + = PanelItemConfig { name :: !Text -- ^ Name of the panel item (this is derived from the section + -- name, which has the form "panel.[name]"). + , position :: !PanelItemPosition -- ^ Position where the item should be placed item within the + -- panel. + } + deriving (Show, Eq) -- | Configuration for icon items in the panel. -data PanelIconConfiguration - = PanelIconConfiguration { iconName :: !Text -- ^ The GTK icon to use for the item. - , action :: !Action -- ^ Action invoked when the user clicks the item. - } - deriving (Show) +data PanelIconConfig + = PanelIconConfig { iconName :: !Text -- ^ The GTK icon to use for the item. + , action :: !Action -- ^ Action invoked when the user clicks the item. + } + deriving (Show, Eq) -- | Configuration for clock items in the panel. -data PanelClockConfiguration - = PanelClockConfiguration { format :: !Text -- ^ The time format string as used by g_date_time_format. - , action :: !Action -- ^ Action invoked when the user clicks the item. - } - deriving (Show) +data PanelClockConfig + = PanelClockConfig { format :: !Text -- ^ The time format string as used by g_date_time_format. + , action :: !Action -- ^ Action invoked when the user clicks the item. + } + deriving (Show, Eq) -- | Configuration for workspace items in the panel. -data PanelWorkspacesConfiguration - = PanelWorkspacesConfiguration { buttonSize :: !Int -- ^ Size of each workspace button. - , spacing :: !Int -- ^ Inner spacing between the workspace buttons. - } - deriving (Show) +data PanelWorkspacesConfig + = PanelWorkspacesConfig { buttonSize :: !Int -- ^ Size of each workspace button. + , spacing :: !Int -- ^ Inner spacing between the workspace buttons. + } + deriving (Show, Eq) -- | Configuration for status items in the panel. -data PanelStatusConfiguration - = PanelStatusConfiguration { items :: ![PanelStatusItem] -- ^ Ordered list of status items to display in - -- the item. - , spacing :: !Int -- ^ Inner spacing between the status items. - , showLabels :: !Bool -- ^ Indicates whether text labels should be shown - -- next to the status icons. - , enableScrolling :: !Bool -- ^ If set to true, some settings like volume or - -- brightness can be adjusted by scrolling while - -- hovering over the status item. - , action :: !Action -- ^ Action invoked when the user clicks the item. - } - deriving (Show) +data PanelStatusConfig + = PanelStatusConfig { items :: ![PanelStatusItem] -- ^ Ordered list of status items to display in the item. + , spacing :: !Int -- ^ Inner spacing between the status items. + , showLabels :: !Bool -- ^ Indicates whether text labels should be shown next + -- to the status icons. + , enableScrolling :: !Bool -- ^ If set to true, some settings like volume or + -- brightness can be adjusted by scrolling while + -- hovering over the status item. + , action :: !Action -- ^ Action invoked when the user clicks the item. + } + deriving (Show, Eq) -- | An item to display in a status panel item. data PanelStatusItem diff --git a/app/Foobar/Services/Coalescing.hs b/app/Foobar/Services/Coalescing.hs new file mode 100644 index 0000000..c44c5eb --- /dev/null +++ b/app/Foobar/Services/Coalescing.hs @@ -0,0 +1,46 @@ +{-# LANGUAGE OverloadedRecordDot, DuplicateRecordFields #-} + +-- | +-- Module : Foobar.Services.Coalescing +-- Description: Coalesce multiple events using a timeout. + +module Foobar.Services.Coalescing + ( EventScheduler + , mkEventScheduler + , scheduleEvent + ) where + +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.Word (Word32) + +import qualified GI.GLib as GLib + +-- | Management structure for coalescing events. +data EventScheduler + = EventScheduler { delay :: !Int + , sourceId :: !(IORef (Maybe Word32)) } + +-- | Create a new event scheduler for coalescing events. +mkEventScheduler + :: Int -- ^ Delay for scheduling events in milliseconds. + -> IO EventScheduler -- ^ The event scheduler structure. +mkEventScheduler delay' = EventScheduler delay' <$> newIORef Nothing + +-- | Schedule an event. +-- +-- If there is already an event scheduled, the two are coalesced. +scheduleEvent + :: EventScheduler -- ^ The event scheduler. + -> IO () -- ^ The event to be scheduled. + -> IO () +scheduleEvent s event = do + existingId <- readIORef s.sourceId + let sourceFunc :: GLib.SourceFunc + sourceFunc = do + event + writeIORef s.sourceId Nothing + pure GLib.SOURCE_REMOVE + case existingId of + Just _ -> pure () + Nothing -> do newId <- GLib.timeoutAdd GLib.PRIORITY_DEFAULT (fromIntegral s.delay) sourceFunc + writeIORef s.sourceId (Just newId) diff --git a/app/Foobar/Services/Config.hs b/app/Foobar/Services/Config.hs index ed1a3ca..d2d3a75 100644 --- a/app/Foobar/Services/Config.hs +++ b/app/Foobar/Services/Config.hs @@ -1,14 +1,15 @@ -{-# LANGUAGE OverloadedStrings, OverloadedRecordDot, OverloadedLabels, DuplicateRecordFields, ScopedTypeVariables, FlexibleInstances, UndecidableInstances #-} +{-# LANGUAGE OverloadedStrings, OverloadedRecordDot, OverloadedLabels, DuplicateRecordFields, ScopedTypeVariables, + FlexibleInstances, UndecidableInstances #-} -- | -- Module : Foobar.Services.Config -- Description: Retrieval of data in the configuration file. module Foobar.Services.Config - ( observeConfiguration - , loadConfiguration - , initConfiguration - , defaultConfiguration + ( observeConfig + , loadConfig + , initConfig + , defaultConfig ) where import Data.Text hiding (filter, elem) @@ -22,28 +23,24 @@ import Foreign.C (CString, CChar, peekCString, withCString) import Foreign.Marshal.Alloc (free) import Foreign (Ptr, nullPtr) import Foobar.Models.Config +import Foobar.Services.Coalescing import qualified GI.GLib as GLib import qualified GI.Gio as Gio -foreign import ccall "realpath" cRealpath :: CString -> CString -> IO (Ptr CChar) - -getRealPath :: FilePath -> IO FilePath -getRealPath p = withCString p $ \cPath -> do - cRes <- cRealpath cPath nullPtr - res <- if cRes /= nullPtr then peekCString cRes else pure p - free cRes - pure res - data MonitoringData = MonitoringData { actualPath :: !FilePath , actualMonitor :: !Gio.FileMonitor , actualHandlerId :: !SignalHandlerId } -observeConfiguration :: IO () -> IO () -observeConfiguration handler = do +-- | Observe the config file for changes. +observeConfig + :: IO () -- ^ Callback invoked whenever the config changes. + -> IO () +observeConfig handler = do path <- getConfigPath actualData <- newIORef (Nothing :: Maybe MonitoringData) + scheduler <- mkEventScheduler 250 let createMonitor :: FilePath -> IO (Maybe Gio.FileMonitor) createMonitor p = do file <- Gio.fileNewForPath p @@ -53,8 +50,8 @@ observeConfiguration handler = do Right m' -> Just m' let actualMonitorHandler :: Gio.File -> Maybe Gio.File -> Gio.FileMonitorEvent -> IO () actualMonitorHandler _ _ eventType = case eventType of - Gio.FileMonitorEventCreated -> handler - Gio.FileMonitorEventChanged -> handler + Gio.FileMonitorEventCreated -> scheduleEvent scheduler handler + Gio.FileMonitorEventChanged -> scheduleEvent scheduler handler _ -> pure () let clearActualMonitor :: IO () clearActualMonitor = do @@ -76,9 +73,9 @@ observeConfiguration handler = do realPath <- getRealPath path let actualPath' = if realPath == path then Nothing else Just realPath actualData' <- readIORef actualData - when ((actualPath <$> actualData') == actualPath') $ - do clearActualMonitor - setActualMonitor actualPath' + when ((actualPath <$> actualData') /= actualPath') $ do + clearActualMonitor + setActualMonitor actualPath' let monitorHandler :: Gio.File -> Maybe Gio.File -> Gio.FileMonitorEvent -> IO () monitorHandler file otherFile eventType = do updateActualMonitor @@ -91,28 +88,31 @@ observeConfiguration handler = do Nothing -> pure () -loadConfiguration :: IO Config -loadConfiguration = do +-- | Load the config file, falling back to a default config if it is invalid. +loadConfig :: IO Config +loadConfig = do path <- getConfigPath f <- GLib.keyFileNew let process = do convertError (throw . ValidationError) $ f.loadFromFile path [] loadSection f "" let handleError (ValidationError msg) = do putStrLn $ "Could not read config: " ++ unpack msg - pure defaultConfiguration + pure defaultConfig catch process handleError -initConfiguration :: IO Config -initConfiguration = do +-- | Create the config file if it doesn't exist, otherwise read it. +initConfig :: IO Config +initConfig = do path <- getConfigPath exists <- GLib.fileTest path [GLib.FileTestExists] - if exists then loadConfiguration + if exists then loadConfig else do f <- GLib.keyFileNew - storeSection f "" defaultConfiguration + storeSection f "" defaultConfig void (try $ f.saveToFile (pack path) :: IO (Either GError ())) - pure defaultConfiguration + pure defaultConfig -defaultConfiguration :: Config -defaultConfiguration = +-- | The default configuration. +defaultConfig :: Config +defaultConfig = Config { general = GeneralConfig { stylesheet = "resource:///foobar/styles/default.css" } , panel = PanelConfig { position = ScreenEdgeLeft , margin = 16 @@ -138,25 +138,25 @@ defaultConfiguration = , spacing = 16 , closeButtonInset = -6 , timeFormat = "%H:%M" } } - where panelItems = [ PanelItemIcon (PanelItemConfiguration { name = "launcher" - , position = PanelItemPositionStart }) - (PanelIconConfiguration { iconName = "fluent-grid-dots-symbolic" - , action = ActionLauncher }) - , PanelItemWorkspaces (PanelItemConfiguration { name = "workspaces" - , position = PanelItemPositionStart }) - (PanelWorkspacesConfiguration { buttonSize = 20 - , spacing = 6 }) - , PanelItemClock (PanelItemConfiguration { name = "clock" - , position = PanelItemPositionCenter }) - (PanelClockConfiguration { format = "%H\n%M" - , action = ActionNone }) - , PanelItemStatus (PanelItemConfiguration { name = "status" - , position = PanelItemPositionEnd }) - (PanelStatusConfiguration { items = statusItems - , spacing = 6 - , showLabels = False - , enableScrolling = False - , action = ActionControlCenter }) ] + where panelItems = [ PanelItemIcon (PanelItemConfig { name = "launcher" + , position = PanelItemPositionStart }) + (PanelIconConfig { iconName = "fluent-grid-dots-symbolic" + , action = ActionLauncher }) + , PanelItemWorkspaces (PanelItemConfig { name = "workspaces" + , position = PanelItemPositionStart }) + (PanelWorkspacesConfig { buttonSize = 20 + , spacing = 6 }) + , PanelItemClock (PanelItemConfig { name = "clock" + , position = PanelItemPositionCenter }) + (PanelClockConfig { format = "%H\n%M" + , action = ActionNone }) + , PanelItemStatus (PanelItemConfig { name = "status" + , position = PanelItemPositionEnd }) + (PanelStatusConfig { items = statusItems + , spacing = 6 + , showLabels = False + , enableScrolling = False + , action = ActionControlCenter }) ] statusItems = [ PanelStatusItemBattery , PanelStatusItemBrightness , PanelStatusItemAudio @@ -167,11 +167,14 @@ defaultConfiguration = , ControlCenterRowAudioOutput , ControlCenterRowBrightness ] +-- | Config file path. getConfigPath :: IO FilePath getConfigPath = do dir <- GLib.getUserConfigDir pure (dir "foobar.conf") +-- Validation errors + newtype ValidationError = ValidationError Text deriving (Show) @@ -182,6 +185,8 @@ instance Exception ValidationError throwValidationError :: Text -> Text -> Text -> IO a throwValidationError s k description = throw $ ValidationError $ s <> ":" <> k <> ": " <> description +-- Key File Serialization + class KeyFileSection a where loadSection :: GLib.KeyFile -> Text -> IO a storeSection :: GLib.KeyFile -> Text -> a -> IO () @@ -287,41 +292,43 @@ instance KeyFileSection PanelItem where storeSection f s x storeSection f s y -instance KeyFileSection PanelItemConfiguration where - loadSection f s = PanelItemConfiguration n <$> loadValue f s "position" [] +instance KeyFileSection PanelItemConfig where + loadSection f s = PanelItemConfig n <$> loadValue f s "position" [] where n = Data.Text.drop 1 $ snd $ breakOn "." s storeSection f s c = do storeValue f s "position" c.position -instance KeyFileSection PanelIconConfiguration where - loadSection f s = PanelIconConfiguration <$> loadValue f s "icon-name" [] - <*> loadValue f s "action" [] +instance KeyFileSection PanelIconConfig where + loadSection f s = PanelIconConfig <$> loadValue f s "icon-name" [] + <*> loadValue f s "action" [] storeSection f s c = do storeValue f s "icon-name" c.iconName storeValue f s "action" c.action -instance KeyFileSection PanelClockConfiguration where - loadSection f s = PanelClockConfiguration <$> loadValue f s "format" [] - <*> loadValue f s "action" [] +instance KeyFileSection PanelClockConfig where + loadSection f s = PanelClockConfig <$> loadValue f s "format" [] + <*> loadValue f s "action" [] storeSection f s c = do storeValue f s "format" c.format storeValue f s "action" c.action -instance KeyFileSection PanelWorkspacesConfiguration where - loadSection f s = PanelWorkspacesConfiguration <$> loadValue f s "button-size" [ validateNonNegative, validateNonZero ] - <*> loadValue f s "spacing" [ validateNonNegative ] +instance KeyFileSection PanelWorkspacesConfig where + loadSection f s = PanelWorkspacesConfig <$> loadValue f s "button-size" [ validateNonNegative, validateNonZero ] + <*> loadValue f s "spacing" [ validateNonNegative ] storeSection f s c = do storeValue f s "button-size" c.buttonSize storeValue f s "spacing" c.spacing -instance KeyFileSection PanelStatusConfiguration where - loadSection f s = PanelStatusConfiguration <$> loadValue f s "items" [ validateDistinct ] - <*> loadValue f s "spacing" [ validateNonNegative ] - <*> loadValue f s "show-labels" [] - <*> loadValue f s "enable-scrolling" [] - <*> loadValue f s "action" [] +instance KeyFileSection PanelStatusConfig where + loadSection f s = PanelStatusConfig <$> loadValue f s "items" [ validateDistinct ] + <*> loadValue f s "spacing" [ validateNonNegative ] + <*> loadValue f s "show-labels" [] + <*> loadValue f s "enable-scrolling" [] + <*> loadValue f s "action" [] storeSection f s c = do storeValue f s "items" c.items storeValue f s "spacing" c.spacing storeValue f s "show-labels" c.showLabels storeValue f s "enable-scrolling" c.enableScrolling storeValue f s "action" c.action +-- Options + class (Bounded o, Enum o, Eq o) => Option o where readOption :: Text -> Maybe o showOption :: o -> Text @@ -450,10 +457,22 @@ instance {-# OVERLAPS #-} Option o => KeyFileValue o where validate s k validators val storeValue f s k val = f.setString s k (showOption val) -convertError :: (Text -> IO a) -> IO a -> IO a +-- Helpers + +-- | Convert GErrors into other exceptions. +convertError + :: (Text -> IO a) -- ^ Conversion function which throws an exception with a given message. + -> IO a -- ^ Operation possibly throwing an exception. + -> IO a -- ^ The same operation throwing the converted exception. convertError t a = catch a (gerrorMessage >=> t) -parseOption :: forall o. Option o => Text -> Text -> Text -> IO o +-- | Parse an option string, otherwise throw a matching validation error. +parseOption + :: forall o. Option o + => Text -- ^ Section name. + -> Text -- ^ Key name. + -> Text -- ^ Option value string. + -> IO o -- ^ The parsed option. parseOption s k str = case readOption str of Just o -> pure o Nothing -> let values = [minBound..] :: [o] in @@ -461,10 +480,18 @@ parseOption s k str = case readOption str of let msg = "Invalid enum value \"" <> str <> "\" (allowed values: " <> allowed <> ")" in throwValidationError s k msg -validate :: Text -> Text -> [Validator a] -> a -> IO a +-- | Apply validators to an input, throwing a matching validation error if one validator fails. +validate + :: Text -- ^ Section name. + -> Text -- ^ Key name. + -> [Validator a] -- ^ List of validators. + -> a -- ^ The input. + -> IO a -- ^ The validated input. validate s k validators val = val <$ forM_ validators (singleValidate val) where singleValidate val' v = v val' >>= mapM_ (throwValidationError s k) +-- Validators + validateFileURL :: Validator Text validateFileURL url = do f <- Gio.fileNewForUri url @@ -484,3 +511,14 @@ validateDistinct :: Option o => Validator [o] validateDistinct (x:xs) = if x `elem` xs then pure $ Just $ "Duplicate value \"" <> showOption x <> "\" not allowed." else validateDistinct xs validateDistinct [] = pure Nothing + +-- C Wrappers + +foreign import ccall "realpath" cRealpath :: CString -> CString -> IO (Ptr CChar) + +getRealPath :: FilePath -> IO FilePath +getRealPath p = withCString p $ \cPath -> do + cRes <- cRealpath cPath nullPtr + res <- if cRes /= nullPtr then peekCString cRes else pure p + free cRes + pure res diff --git a/app/foobar.cabal b/app/foobar.cabal index c48349c..f4841cf 100644 --- a/app/foobar.cabal +++ b/app/foobar.cabal @@ -25,5 +25,6 @@ executable foobar directory other-modules: Foobar.Application, Foobar.Models.Config, - Foobar.Services.Config + Foobar.Services.Config, + Foobar.Services.Coalescing default-language: Haskell2010 -- cgit v1.3.1