tui-launcher (empty) → 0.0.1
raw patch · 15 files changed
+1506/−0 lines, 15 filesdep +basedep +brickdep +containersbinary-added
Dependencies added: base, brick, containers, directory, filepath, optparse-applicative, process, tasty, tasty-hunit, text, tomland, tuispec, unix, vty, vty-crossplatform
Files
- CHANGELOG.md +10/−0
- LICENSE +21/−0
- README.md +168/−0
- app/Main.hs +8/−0
- docs/screenshots/github-dark-square.png binary
- docs/screenshots/github-dark-vertical.png binary
- docs/screenshots/github-light-square.png binary
- docs/screenshots/github-light-vertical.png binary
- src/TuiLauncher/App.hs +64/−0
- src/TuiLauncher/Config.hs +291/−0
- src/TuiLauncher/Themes.hs +122/−0
- src/TuiLauncher/Types.hs +195/−0
- src/TuiLauncher/UI.hs +426/−0
- test/Main.hs +87/−0
- tui-launcher.cabal +114/−0
+ CHANGELOG.md view
@@ -0,0 +1,10 @@+# Changelog++## v0.0.1 - 2026-03-17++- Initial public release of `tui-launcher`.+- Brick-based terminal launcher with keyboard and mouse navigation.+- TOML configuration with per-entry `working-dir`, `shell-program`, `shell-login`, and `color`.+- Auto-created default config at `~/.config/tui-launcher/config.toml`.+- End-to-end TuiSpec test coverage for explicit config loading, arrow-key navigation, and shell launching.+- Screenshot generation with dark and light terminal renders for the README.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,168 @@+# tui-launcher++[](https://hackage.haskell.org/package/tui-launcher)++Small Brick-based terminal launcher for shell commands. It reads a TOML config,+shows a keyboard-driven tile menu, and replaces itself with the selected+command.++## Screenshots++Both dark and light terminal styles are shown in square and vertical layouts.++++++++++## Installation++Install from Hackage:++```sh+cabal install tui-launcher+```++Or download the latest release binary from GitHub Releases and put it on your+`PATH`:++```sh+curl -Lo tui-launcher \+ https://github.com/TharkunAB/tui-launcher/releases/latest/download/tui-launcher-linux-x86_64+chmod +x tui-launcher+install -Dm755 tui-launcher ~/.local/bin/tui-launcher+```++Then run:++```sh+tui-launcher+```++On first launch, `tui-launcher` creates `~/.config/tui-launcher/config.toml` if+it does not already exist.++## Features++- Arrow keys and `hjkl` move the selection+- `Enter` launches the selected entry+- Mouse click launches an entry and mouse wheel scrolls+- Dedicated `Exit` button at the bottom of the launcher+- Optional per-entry `color` for tile text+- Per-entry `working-dir`, `shell-program`, and `shell-login`+- Auto-created default config at `~/.config/tui-launcher/config.toml`+- Uses your terminal's default colors++## Configuration++Use the default config path:++```sh+tui-launcher+```++Or point at an explicit file:++```sh+tui-launcher --config /path/to/config.toml+```++Example config:++```toml+[layout]+tile-width = 20+tile-height = 5+tile-spacing = 1++# [shell]+# program = "/bin/bash"+# login = false++[[entries]]+name = "Shell"+command = "exec \"${SHELL:-/bin/sh}\""+color = "bright-blue"++[[entries]]+name = "nvim"+command = "nvim"+color = "blue"+working-dir = "~/Code"++[[entries]]+name = "Codex"+command = "codex"+color = "bright-magenta"++[[entries]]+name = "Claude"+command = "claude"+color = "orange"++[[entries]]+name = "Tmux"+command = "tmux"+color = "cyan"+working-dir = "~/Code/project"+shell-program = "/bin/zsh"+shell-login = true+```++Layout settings:++- `tile-width`: positive integer, default `20`+- `tile-height`: positive integer, default `5`+- `tile-spacing`: non-negative integer, default `1`++Shell resolution order:++- `entries.shell-program`+- `[shell].program`+- `$SHELL`+- `/bin/sh`++Relative `working-dir` resolution:++- default config: relative to `$HOME`+- `--config /path/to/config.toml`: relative to that config file's directory++Supported entry colors:++- `black`+- `red`+- `orange`+- `green`+- `yellow`+- `blue`+- `magenta`+- `cyan`+- `white`+- `bright-black`+- `bright-red`+- `bright-green`+- `bright-yellow`+- `bright-blue`+- `bright-magenta`+- `bright-cyan`+- `bright-white`++## Development++Use the provided Nix shell for the project toolchain:++```sh+nix-shell+```++Common commands:++```sh+make build+make test+make lint+nix-shell --command "make snapshots"+```
+ app/Main.hs view
@@ -0,0 +1,8 @@+-- | Executable entry point for @tui-launcher@.+module Main (main) where++import TuiLauncher.App qualified as App++-- | Run the application.+main :: IO ()+main = App.main
+ docs/screenshots/github-dark-square.png view
binary file changed (absent → 7367 bytes)
+ docs/screenshots/github-dark-vertical.png view
binary file changed (absent → 8197 bytes)
+ docs/screenshots/github-light-square.png view
binary file changed (absent → 8412 bytes)
+ docs/screenshots/github-light-vertical.png view
binary file changed (absent → 9404 bytes)
+ src/TuiLauncher/App.hs view
@@ -0,0 +1,64 @@+-- | Application startup and process handoff.+module TuiLauncher.App (+ main,+ launch,+) where++import Control.Exception (SomeException, displayException, try)+import Data.Text qualified as T+import Options.Applicative+import System.Directory (setCurrentDirectory)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)+import System.Posix.Process (executeFile)+import TuiLauncher.Config (loadResolvedConfig)+import TuiLauncher.Types+import TuiLauncher.UI (runUi)++{- | Parse CLI options, load configuration, run the UI, and launch the+selected entry when one is chosen.+-}+main :: IO ()+main = do+ options <- execParser parserInfo+ resolved <- loadResolvedConfig (appConfigOverride options)+ selected <- runUi (resolvedConfig resolved)+ maybe (pure ()) launch selected++-- | Parser metadata for the command-line interface.+parserInfo :: ParserInfo AppOptions+parserInfo =+ info+ (optionsParser <**> helper)+ (fullDesc <> progDesc "Launch shell commands from a Brick-based menu")++-- | Supported command-line options.+optionsParser :: Parser AppOptions+optionsParser =+ AppOptions+ <$> optional+ ( strOption+ ( long "config"+ <> metavar "PATH"+ <> help "Override the default config path"+ )+ )++{- | Replace the current process with the selected command.++The launcher optionally changes directory first and then executes the+resolved shell with either @-c@ or @-lc@ depending on the login setting.+-}+launch :: LaunchSpec -> IO ()+launch LaunchSpec{..} = do+ let shellArgs = (if shellLogin launchShell then ["-lc"] else ["-c"]) <> [T.unpack launchCommand]+ result <- try @SomeException do+ maybe (pure ()) setCurrentDirectory launchWorkingDir+ _ <- executeFile (shellProgram launchShell) True shellArgs Nothing+ pure ()+ case result of+ Left err -> do+ hPutStrLn stderr ("Failed to launch command: " <> displayException err)+ exitFailure+ Right _ ->+ pure ()
+ src/TuiLauncher/Config.hs view
@@ -0,0 +1,291 @@+{-# LANGUAGE MultilineStrings #-}++-- | Config file loading, validation, and normalization.+module TuiLauncher.Config (+ defaultConfigText,+ loadResolvedConfig,+) where++import Control.Applicative ((<|>))+import Control.Monad (unless, when)+import Data.List (isPrefixOf)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import System.Directory (+ createDirectoryIfMissing,+ doesFileExist,+ getHomeDirectory,+ )+import System.Environment (lookupEnv)+import System.FilePath (isAbsolute, takeDirectory, (</>))+import Toml qualified+import TuiLauncher.Types++{- | Starter config written on first launch when no default config exists.++The generated file keeps only the shell entry enabled and leaves the other+starter entries in place as commented examples.+-}+defaultConfigText :: T.Text+defaultConfigText =+ """+ [layout]+ tile-width = 20+ tile-height = 5+ tile-spacing = 1++ # [shell]+ # program = "/bin/bash"+ # login = false++ [[entries]]+ name = "Shell"+ command = "exec \\\"${SHELL:-/bin/sh}\\\""+ # color = "bright-blue"++ # [[entries]]+ # name = "nvim"+ # command = "nvim"+ # color = "blue"+ # working-dir = "~/Code"++ # [[entries]]+ # name = "Tmux"+ # command = "tmux"+ # color = "cyan"+ # working-dir = "~/Code/project"+ # shell-program = "/bin/zsh"+ # shell-login = true++ # [[entries]]+ # name = "Codex"+ # command = "codex"+ # color = "bright-magenta"++ # [[entries]]+ # name = "Claude"+ # command = "claude"+ # color = "orange"+ """++-- | Load, parse, validate, and normalize the active config file.+loadResolvedConfig :: Maybe FilePath -> IO ResolvedConfig+loadResolvedConfig overridePath = do+ home <- getHomeDirectory+ (configLocation, configPath, autoCreated) <- resolveConfigPath home overridePath+ decoded <- Toml.decodeFileEither rawConfigCodec configPath+ rawConfig <- either (fail . T.unpack . Toml.prettyTomlDecodeErrors) pure decoded+ validated <- validateConfig home configLocation rawConfig+ pure+ ResolvedConfig+ { resolvedPath = configPath+ , resolvedLocation = configLocation+ , resolvedAutoCreated = autoCreated+ , resolvedConfig = validated+ }++-- | Resolve which config file to use and create the default one if needed.+resolveConfigPath :: FilePath -> Maybe FilePath -> IO (ConfigLocation, FilePath, Bool)+resolveConfigPath home = \case+ Nothing -> do+ let defaultPath = home </> ".config" </> "tui-launcher" </> "config.toml"+ exists <- doesFileExist defaultPath+ if exists+ then pure (DefaultConfig defaultPath, defaultPath, False)+ else do+ createDirectoryIfMissing True (takeDirectory defaultPath)+ TIO.writeFile defaultPath defaultConfigText+ pure (DefaultConfig defaultPath, defaultPath, True)+ Just path -> do+ expanded <- expandPath home path+ exists <- doesFileExist expanded+ unless exists $+ fail ("Config file does not exist: " <> expanded)+ pure (OverrideConfig expanded, expanded, False)++-- | Expand a leading @~@ against the supplied home directory.+expandPath :: FilePath -> FilePath -> IO FilePath+expandPath home path+ | "~/" `isPrefixOf` path = pure (home </> drop 2 path)+ | path == "~" = pure home+ | otherwise = pure path++-- | Validate raw TOML data into the application config used by the UI.+validateConfig :: FilePath -> ConfigLocation -> RawConfig -> IO AppConfig+validateConfig home location RawConfig{..} = do+ layout <- validateLayout rawLayout+ entries <- validateEntries home location rawShell rawEntries+ pure+ AppConfig+ { configLayout = layout+ , configEntries = entries+ }++-- | Validate layout values and apply defaults for missing settings.+validateLayout :: Maybe RawLayout -> IO LayoutConfig+validateLayout maybeLayout = do+ let raw = fromMaybe (RawLayout Nothing Nothing Nothing) maybeLayout+ width = fromMaybe (layoutTileWidth defaultLayout) (rawTileWidth raw)+ height = fromMaybe (layoutTileHeight defaultLayout) (rawTileHeight raw)+ spacing = fromMaybe (layoutTileSpacing defaultLayout) (rawTileSpacing raw)+ when (width <= 0) $ fail "layout.tile-width must be positive"+ when (height <= 0) $ fail "layout.tile-height must be positive"+ when (spacing < 0) $ fail "layout.tile-spacing must be non-negative"+ pure+ LayoutConfig+ { layoutTileWidth = width+ , layoutTileHeight = height+ , layoutTileSpacing = spacing+ }++-- | Validate and resolve all configured entries.+validateEntries :: FilePath -> ConfigLocation -> Maybe RawShell -> [EntryConfig] -> IO (NonEmpty ResolvedEntry)+validateEntries home location globalShell entries = do+ resolvedEntries <- traverse (resolveEntry home location globalShell) entries+ case resolvedEntries of+ [] -> fail "At least one [[entries]] table is required"+ entry : rest -> pure (entry :| rest)++-- | Resolve a single entry into the runtime format used by the launcher.+resolveEntry :: FilePath -> ConfigLocation -> Maybe RawShell -> EntryConfig -> IO ResolvedEntry+resolveEntry home location globalShell EntryConfig{..} = do+ let strippedName = T.strip entryName+ strippedCommand = T.strip entryCommand+ when (T.null strippedName) $ fail "entries.name must not be empty"+ when (T.null strippedCommand) $ fail "entries.command must not be empty"+ color <- traverse resolveEntryColor entryColor+ workingDir <- traverse (resolveWorkingDir home location) entryWorkingDir+ shellProgramText <- resolveShellProgram home globalShell entryShellProgram+ let login = fromMaybe (maybe False (fromMaybe False . rawShellLogin) globalShell) entryShellLogin+ pure+ ResolvedEntry+ { resolvedName = strippedName+ , resolvedCommand = strippedCommand+ , resolvedColor = color+ , resolvedWorkingDirDisplay = fmap (displayWorkingDir home) workingDir+ , resolvedWorkingDir = workingDir+ , resolvedShell =+ ShellConfig+ { shellProgram = T.unpack shellProgramText+ , shellLogin = login+ }+ }++-- | Resolve the shell program for an entry using the configured precedence.+resolveShellProgram :: FilePath -> Maybe RawShell -> Maybe T.Text -> IO T.Text+resolveShellProgram _home globalOverride entryOverride =+ case entryOverride <|> (globalOverride >>= rawShellProgram) of+ Just program -> validateShellProgram program+ Nothing -> do+ envShell <- lookupShell+ pure $+ case fmap (T.strip . T.pack) envShell of+ Just shellPath | not (T.null shellPath) -> shellPath+ _ -> "/bin/sh"++-- | Read the current shell from the environment.+lookupShell :: IO (Maybe FilePath)+lookupShell = lookupEnv "SHELL"++-- | Reject blank shell program values.+validateShellProgram :: T.Text -> IO T.Text+validateShellProgram rawProgram = do+ let stripped = T.strip rawProgram+ when (T.null stripped) $+ fail "shell.program and entries.shell-program must not be empty"+ pure stripped++-- | Resolve an entry working directory relative to the proper base path.+resolveWorkingDir :: FilePath -> ConfigLocation -> T.Text -> IO FilePath+resolveWorkingDir home location rawPath = do+ let strippedPath = T.strip rawPath+ when (T.null strippedPath) $+ fail "entries.working-dir must not be empty"+ let pathString = T.unpack strippedPath+ expanded <- expandPath home pathString+ if isAbsolute expanded+ then pure expanded+ else pure (baseDirectory location home </> expanded)++-- | Determine the base directory for relative path resolution.+baseDirectory :: ConfigLocation -> FilePath -> FilePath+baseDirectory location home = case location of+ DefaultConfig _ -> home+ OverrideConfig path -> takeDirectory path++-- | Render a resolved working directory with @~@ for the user's home.+displayWorkingDir :: FilePath -> FilePath -> T.Text+displayWorkingDir home path+ | path == home = "~"+ | (home <> "/") `isPrefixOf` path =+ T.pack ("~/" <> drop (length home + 1) path)+ | otherwise = T.pack path++-- | Validate and normalize an optional entry color.+resolveEntryColor :: T.Text -> IO EntryColor+resolveEntryColor rawColor = do+ let normalized = T.toLower (T.strip rawColor)+ when (T.null normalized) $+ fail "entries.color must not be empty"+ case normalized of+ "black" -> pure EntryBlack+ "red" -> pure EntryRed+ "orange" -> pure EntryOrange+ "green" -> pure EntryGreen+ "yellow" -> pure EntryYellow+ "blue" -> pure EntryBlue+ "magenta" -> pure EntryMagenta+ "cyan" -> pure EntryCyan+ "white" -> pure EntryWhite+ "bright-black" -> pure EntryBrightBlack+ "bright-red" -> pure EntryBrightRed+ "bright-green" -> pure EntryBrightGreen+ "bright-yellow" -> pure EntryBrightYellow+ "bright-blue" -> pure EntryBrightBlue+ "bright-magenta" -> pure EntryBrightMagenta+ "bright-cyan" -> pure EntryBrightCyan+ "bright-white" -> pure EntryBrightWhite+ "gray" -> pure EntryBrightBlack+ "grey" -> pure EntryBrightBlack+ other ->+ fail+ ( "Unsupported entries.color: "+ <> T.unpack other+ <> ". Use an ANSI color name like red, bright-blue, or cyan."+ )++-- | TOML codec for the top-level config structure.+rawConfigCodec :: Toml.TomlCodec RawConfig+rawConfigCodec =+ RawConfig+ <$> (Toml.dioptional (Toml.table rawLayoutCodec "layout") Toml..= rawLayout)+ <*> (Toml.dioptional (Toml.table rawShellCodec "shell") Toml..= rawShell)+ <*> (Toml.list entryCodec "entries" Toml..= rawEntries)++-- | TOML codec for the layout table.+rawLayoutCodec :: Toml.TomlCodec RawLayout+rawLayoutCodec =+ RawLayout+ <$> (Toml.dioptional (Toml.int "tile-width") Toml..= rawTileWidth)+ <*> (Toml.dioptional (Toml.int "tile-height") Toml..= rawTileHeight)+ <*> (Toml.dioptional (Toml.int "tile-spacing") Toml..= rawTileSpacing)++-- | TOML codec for global shell defaults.+rawShellCodec :: Toml.TomlCodec RawShell+rawShellCodec =+ RawShell+ <$> (Toml.dioptional (Toml.text "program") Toml..= rawShellProgram)+ <*> (Toml.dioptional (Toml.bool "login") Toml..= rawShellLogin)++-- | TOML codec for launcher entries.+entryCodec :: Toml.TomlCodec EntryConfig+entryCodec =+ EntryConfig+ <$> (Toml.text "name" Toml..= entryName)+ <*> (Toml.text "command" Toml..= entryCommand)+ <*> (Toml.dioptional (Toml.text "color") Toml..= entryColor)+ <*> (Toml.dioptional (Toml.text "working-dir") Toml..= entryWorkingDir)+ <*> (Toml.dioptional (Toml.text "shell-program") Toml..= entryShellProgram)+ <*> (Toml.dioptional (Toml.bool "shell-login") Toml..= entryShellLogin)
+ src/TuiLauncher/Themes.hs view
@@ -0,0 +1,122 @@+-- | Brick attribute definitions that defer colors to the active terminal theme.+module TuiLauncher.Themes (+ accentAttr,+ attrMapForTerminal,+ baseAttr,+ borderAttr,+ entryCommandAttr,+ exitAttr,+ exitFocusedAttr,+ focusedAttr,+ focusedTitleAttr,+ mutedAttr,+) where++import Brick (AttrMap, AttrName, attrMap, attrName)+import Graphics.Vty qualified as V+import TuiLauncher.Types (AppConfig (..), EntryColor (..), ResolvedEntry (..))++-- | Default text and background styling.+baseAttr :: AttrName+baseAttr = attrName "base"++-- | Border styling for tiles and layout chrome.+borderAttr :: AttrName+borderAttr = attrName "border"++-- | Secondary text styling for commands.+mutedAttr :: AttrName+mutedAttr = attrName "muted"++-- | Accent styling for headings.+accentAttr :: AttrName+accentAttr = attrName "accent"++-- | Styling for the currently focused tile.+focusedAttr :: AttrName+focusedAttr = attrName "focused"++-- | Styling for the title inside the currently focused tile.+focusedTitleAttr :: AttrName+focusedTitleAttr = attrName "focused.title"++-- | Styling for the exit button when it is not selected.+exitAttr :: AttrName+exitAttr = attrName "exit"++-- | Styling for the exit button when it is selected.+exitFocusedAttr :: AttrName+exitFocusedAttr = attrName "exit.focused"++-- | Attribute name for the colored command text inside an entry tile.+entryCommandAttr :: Maybe EntryColor -> AttrName+entryCommandAttr maybeColor =+ attrName ("entry.command." <> maybe "default" renderEntryColor maybeColor)++-- | Build the Brick attribute map using terminal-default colors.+attrMapForTerminal :: AppConfig -> AttrMap+attrMapForTerminal config =+ attrMap+ V.defAttr+ ( [ (baseAttr, V.defAttr)+ , (borderAttr, V.withForeColor V.defAttr V.brightBlack)+ , (mutedAttr, V.withStyle V.defAttr V.dim)+ , (accentAttr, V.withStyle V.defAttr V.bold)+ , (focusedAttr, V.withStyle V.defAttr V.bold)+ , (focusedTitleAttr, V.withStyle V.defAttr V.bold)+ , (exitAttr, V.withForeColor V.defAttr V.brightBlack)+ , (exitFocusedAttr, V.withStyle V.defAttr V.bold)+ ]+ <> concatMap entryColorAttrs (configEntries config)+ )++-- | Build text attrs for an entry's configured color.+entryColorAttrs :: ResolvedEntry -> [(AttrName, V.Attr)]+entryColorAttrs entry =+ case resolvedColor entry of+ Nothing -> [(entryCommandAttr Nothing, V.withStyle V.defAttr V.dim)]+ Just colorValue ->+ let color = entryColorToVty colorValue+ in [(entryCommandAttr (Just colorValue), V.withForeColor V.defAttr color)]++-- | Render an entry color to a stable attribute suffix.+renderEntryColor :: EntryColor -> String+renderEntryColor = \case+ EntryBlack -> "black"+ EntryRed -> "red"+ EntryOrange -> "orange"+ EntryGreen -> "green"+ EntryYellow -> "yellow"+ EntryBlue -> "blue"+ EntryMagenta -> "magenta"+ EntryCyan -> "cyan"+ EntryWhite -> "white"+ EntryBrightBlack -> "bright-black"+ EntryBrightRed -> "bright-red"+ EntryBrightGreen -> "bright-green"+ EntryBrightYellow -> "bright-yellow"+ EntryBrightBlue -> "bright-blue"+ EntryBrightMagenta -> "bright-magenta"+ EntryBrightCyan -> "bright-cyan"+ EntryBrightWhite -> "bright-white"++-- | Map an entry color to the corresponding Vty color.+entryColorToVty :: EntryColor -> V.Color+entryColorToVty = \case+ EntryBlack -> V.black+ EntryRed -> V.red+ EntryOrange -> V.rgbColor (255 :: Int) 165 0+ EntryGreen -> V.green+ EntryYellow -> V.yellow+ EntryBlue -> V.blue+ EntryMagenta -> V.magenta+ EntryCyan -> V.cyan+ EntryWhite -> V.white+ EntryBrightBlack -> V.brightBlack+ EntryBrightRed -> V.brightRed+ EntryBrightGreen -> V.brightGreen+ EntryBrightYellow -> V.brightYellow+ EntryBrightBlue -> V.brightBlue+ EntryBrightMagenta -> V.brightMagenta+ EntryBrightCyan -> V.brightCyan+ EntryBrightWhite -> V.brightWhite
+ src/TuiLauncher/Types.hs view
@@ -0,0 +1,195 @@+-- | Shared configuration and UI domain types.+module TuiLauncher.Types (+ AppConfig (..),+ AppOptions (..),+ ConfigLocation (..),+ EntryColor (..),+ EntryConfig (..),+ GridMetrics (..),+ LayoutConfig (..),+ LaunchSpec (..),+ RawConfig (..),+ RawLayout (..),+ RawShell (..),+ ResolvedConfig (..),+ ResolvedEntry (..),+ ShellConfig (..),+ defaultLayout,+) where++import Data.List.NonEmpty (NonEmpty)+import Data.Text (Text)++-- | Command-line options accepted by the executable.+newtype AppOptions = AppOptions+ { appConfigOverride :: Maybe FilePath+ -- ^ Optional explicit config path from @--config@.+ }+ deriving (Eq, Show)++-- | Where the active configuration file was loaded from.+data ConfigLocation+ = -- | The default config under @~/.config/tui-launcher/config.toml@.+ DefaultConfig FilePath+ | -- | A path supplied explicitly via @--config@.+ OverrideConfig FilePath+ deriving (Eq, Show)++-- | Supported ANSI text colors for entry labels and commands.+data EntryColor+ = EntryBlack+ | EntryRed+ | EntryOrange+ | EntryGreen+ | EntryYellow+ | EntryBlue+ | EntryMagenta+ | EntryCyan+ | EntryWhite+ | EntryBrightBlack+ | EntryBrightRed+ | EntryBrightGreen+ | EntryBrightYellow+ | EntryBrightBlue+ | EntryBrightMagenta+ | EntryBrightCyan+ | EntryBrightWhite+ deriving (Bounded, Enum, Eq, Ord, Show)++-- | Raw layout settings parsed directly from TOML.+data RawLayout = RawLayout+ { rawTileWidth :: Maybe Int+ -- ^ Optional tile width override.+ , rawTileHeight :: Maybe Int+ -- ^ Optional tile height override.+ , rawTileSpacing :: Maybe Int+ -- ^ Optional tile spacing override.+ }+ deriving (Eq, Show)++-- | Raw shell defaults parsed directly from TOML.+data RawShell = RawShell+ { rawShellProgram :: Maybe Text+ -- ^ Optional shell executable.+ , rawShellLogin :: Maybe Bool+ -- ^ Optional login-shell flag.+ }+ deriving (Eq, Show)++-- | Raw entry table parsed directly from TOML.+data EntryConfig = EntryConfig+ { entryName :: Text+ -- ^ Label shown in the UI.+ , entryCommand :: Text+ -- ^ Command passed to the selected shell.+ , entryColor :: Maybe Text+ -- ^ Optional text color for this entry.+ , entryWorkingDir :: Maybe Text+ -- ^ Optional working directory before launch.+ , entryShellProgram :: Maybe Text+ -- ^ Optional per-entry shell override.+ , entryShellLogin :: Maybe Bool+ -- ^ Optional per-entry login-shell flag.+ }+ deriving (Eq, Show)++-- | Top-level TOML structure before validation and normalization.+data RawConfig = RawConfig+ { rawLayout :: Maybe RawLayout+ -- ^ Optional layout table.+ , rawShell :: Maybe RawShell+ -- ^ Optional global shell defaults.+ , rawEntries :: [EntryConfig]+ -- ^ Launcher entries in display order.+ }+ deriving (Eq, Show)++-- | Validated layout settings used by the UI.+data LayoutConfig = LayoutConfig+ { layoutTileWidth :: Int+ -- ^ Width of each tile in terminal cells.+ , layoutTileHeight :: Int+ -- ^ Height of each tile in terminal cells.+ , layoutTileSpacing :: Int+ -- ^ Horizontal and vertical spacing between tiles.+ }+ deriving (Eq, Show)++-- | Built-in default layout used when the config omits layout values.+defaultLayout :: LayoutConfig+defaultLayout =+ LayoutConfig+ { layoutTileWidth = 20+ , layoutTileHeight = 5+ , layoutTileSpacing = 1+ }++-- | Shell program and flags used to execute a launch command.+data ShellConfig = ShellConfig+ { shellProgram :: FilePath+ -- ^ Executable name or path for the shell.+ , shellLogin :: Bool+ -- ^ Whether the shell should be invoked as a login shell.+ }+ deriving (Eq, Show)++-- | A fully validated entry ready for display in the UI.+data ResolvedEntry = ResolvedEntry+ { resolvedName :: Text+ -- ^ Label rendered in the tile.+ , resolvedCommand :: Text+ -- ^ Command string passed to the shell.+ , resolvedColor :: Maybe EntryColor+ -- ^ Optional terminal color used for the tile text.+ , resolvedWorkingDirDisplay :: Maybe Text+ -- ^ Optional working directory label rendered in the tile.+ , resolvedWorkingDir :: Maybe FilePath+ -- ^ Optional working directory applied before launch.+ , resolvedShell :: ShellConfig+ -- ^ Resolved shell configuration for this entry.+ }+ deriving (Eq, Show)++-- | Validated application configuration consumed by the UI.+data AppConfig = AppConfig+ { configLayout :: LayoutConfig+ -- ^ Layout parameters for the tile grid.+ , configEntries :: NonEmpty ResolvedEntry+ -- ^ Entries shown in the launcher.+ }+ deriving (Eq, Show)++-- | Full result of loading and validating the configuration file.+data ResolvedConfig = ResolvedConfig+ { resolvedPath :: FilePath+ -- ^ Final path used to load the config.+ , resolvedLocation :: ConfigLocation+ -- ^ Whether the path was default or overridden.+ , resolvedAutoCreated :: Bool+ -- ^ Whether the default config had to be created on startup.+ , resolvedConfig :: AppConfig+ -- ^ Validated application configuration.+ }+ deriving (Eq, Show)++-- | Launch request returned by the UI when the user selects an entry.+data LaunchSpec = LaunchSpec+ { launchCommand :: Text+ -- ^ Command string passed to the shell.+ , launchWorkingDir :: Maybe FilePath+ -- ^ Optional working directory to switch to before launch.+ , launchShell :: ShellConfig+ -- ^ Shell configuration used to execute the command.+ }+ deriving (Eq, Show)++-- | Derived grid sizing information for a particular terminal size.+data GridMetrics = GridMetrics+ { gridColumns :: Int+ -- ^ Number of tile columns that fit horizontally.+ , gridVisibleRows :: Int+ -- ^ Number of tile rows visible without scrolling.+ , gridTotalRows :: Int+ -- ^ Total number of tile rows in the entry list.+ }+ deriving (Eq, Show)
+ src/TuiLauncher/UI.hs view
@@ -0,0 +1,426 @@+-- | Brick user interface for browsing and selecting launcher entries.+module TuiLauncher.UI (+ runUi,+) where++import Brick (+ App (..),+ BrickEvent (..),+ EventM,+ Padding (Pad),+ Widget,+ clickable,+ customMain,+ fill,+ hBox,+ hLimit,+ padBottom,+ padRight,+ padTop,+ txt,+ vBox,+ vLimit,+ withAttr,+ withBorderStyle,+ (<=>),+ )+import Brick.Main qualified as Brick+import Brick.Types (get, put)+import Brick.Widgets.Border (border)+import Brick.Widgets.Border.Style (unicode, unicodeBold)+import Brick.Widgets.Center (center, hCenter)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Text (Text)+import Data.Text qualified as T+import Graphics.Vty qualified as V+import Graphics.Vty.Config (defaultConfig)+import Graphics.Vty.CrossPlatform (mkVty)+import TuiLauncher.Themes+import TuiLauncher.Types++-- | Resource names used by Brick for clickable tiles.+data Name+ = TileName Int+ | ExitName+ deriving (Eq, Ord, Show)++-- | Fixed width of the exit button.+exitButtonWidth :: Int+exitButtonWidth = 20++-- | Fixed height of the exit button.+exitButtonHeight :: Int+exitButtonHeight = 3++-- | Spacing between the tile grid and the exit button.+exitButtonSpacing :: Int+exitButtonSpacing = 1++-- | Full UI state tracked during the Brick event loop.+data UiState = UiState+ { uiConfig :: AppConfig+ -- ^ Validated application config.+ , uiSelected :: Int+ -- ^ Index of the currently focused entry.+ , uiScrollRow :: Int+ -- ^ First visible row in the grid.+ , uiWidth :: Int+ -- ^ Last known terminal width.+ , uiHeight :: Int+ -- ^ Last known terminal height.+ , uiLaunch :: Maybe LaunchSpec+ -- ^ Launch request chosen by the user, if any.+ }++-- | Run the Brick UI and return the selected launch request, if any.+runUi :: AppConfig -> IO (Maybe LaunchSpec)+runUi config = do+ initialVty <- buildVty+ (w, h) <- V.displayBounds (V.outputIface initialVty)+ finalState <-+ customMain+ initialVty+ buildVty+ Nothing+ app+ UiState+ { uiConfig = config+ , uiSelected = 0+ , uiScrollRow = 0+ , uiWidth = w+ , uiHeight = h+ , uiLaunch = Nothing+ }+ pure (uiLaunch finalState)++-- | Build a Vty instance with mouse reporting enabled.+buildVty :: IO V.Vty+buildVty = do+ vty <- mkVty defaultConfig+ V.setMode (V.outputIface vty) V.Mouse True+ pure vty++-- | Brick application definition.+app :: App UiState e Name+app =+ App+ { appDraw = drawUi+ , appChooseCursor = Brick.neverShowCursor+ , appHandleEvent = handleEvent+ , appStartEvent = pure ()+ , appAttrMap = attrMapForTerminal . uiConfig+ }++-- | Draw the complete screen.+drawUi :: UiState -> [Widget Name]+drawUi state =+ [ withAttr baseAttr $+ header state+ <=> padTop (Pad 1) (drawBody state)+ , withAttr baseAttr (fill ' ')+ ]++-- | Render the application title.+header :: UiState -> Widget Name+header _state =+ withAttr accentAttr $+ hCenter $+ txt "tui-launcher"++-- | Render the grid body or the small-window fallback.+drawBody :: UiState -> Widget Name+drawBody state+ | tooSmall = center $ withAttr mutedAttr $ txt "Window too small"+ | otherwise =+ vBox+ [ vBox (fmap hCenter visibleRowsWidgets)+ , fill ' '+ , padTop (Pad exitButtonSpacing) $+ hCenter $+ drawExitButton state+ ]+ where+ layout = configLayout (uiConfig state)+ metrics = gridMetrics layout (uiWidth state) (uiHeight state) (entryCount state)+ tooSmall =+ max (layoutTileWidth layout) exitButtonWidth > uiWidth state+ || minimumHeight layout > uiHeight state+ entries = zip [0 ..] (NonEmpty.toList (configEntries (uiConfig state)))+ rows = chunk (gridColumns metrics) entries+ visibleRows = take (gridVisibleRows metrics) (drop (uiScrollRow state) rows)+ visibleRowsWidgets = zipWith (drawRow state) [uiScrollRow state ..] visibleRows++-- | Render one visible row of tiles.+drawRow :: UiState -> Int -> [(Int, ResolvedEntry)] -> Widget Name+drawRow state _rowIndex entriesInRow =+ padBottom (Pad (layoutTileSpacing (configLayout (uiConfig state)))) $+ hBox $+ zipWith+ ( \position tileWidget ->+ if position == length entriesInRow - 1+ then tileWidget+ else padRight (Pad (layoutTileSpacing (configLayout (uiConfig state)))) tileWidget+ )+ [0 ..]+ (fmap (uncurry (drawTile state)) entriesInRow)++-- | Render a single selectable tile.+drawTile :: UiState -> Int -> ResolvedEntry -> Widget Name+drawTile state index entry =+ clickable (TileName index) $+ hLimit (layoutTileWidth layout) $+ vLimit (layoutTileHeight layout) $+ withBorderStyle borderStyle $+ withAttr borderStyleAttr $+ border $+ withAttr bodyStyleAttr $+ vBox+ [ vLimit topPad (fill ' ')+ , center $+ withAttr titleStyleAttr $+ txt titleText+ , center $+ withAttr commandStyleAttr $+ txt $+ truncateText innerWidth (resolvedCommand entry)+ , drawWorkingDirLine+ , fill ' '+ ]+ where+ layout = configLayout (uiConfig state)+ isSelected = index == uiSelected state+ innerWidth = max 1 (layoutTileWidth layout - 2)+ innerHeight = max 1 (layoutTileHeight layout - 2)+ lineCount = 2 + maybe 0 (const 1) (resolvedWorkingDirDisplay entry)+ topPad = max 0 ((innerHeight - lineCount) `div` 2)+ borderStyle = if isSelected then unicodeBold else unicode+ borderStyleAttr = if isSelected then focusedAttr else borderAttr+ bodyStyleAttr = baseAttr+ titleStyleAttr =+ if isSelected+ then focusedTitleAttr+ else mutedAttr+ commandStyleAttr = entryCommandAttr (resolvedColor entry)+ titleText =+ if isSelected+ then "> " <> resolvedName entry+ else resolvedName entry+ drawWorkingDirLine =+ case resolvedWorkingDirDisplay entry of+ Nothing -> fill ' '+ Just workingDirText ->+ center $+ withAttr mutedAttr $+ txt (truncateText innerWidth workingDirText)++-- | Render the fixed exit button shown below the launcher entries.+drawExitButton :: UiState -> Widget Name+drawExitButton state =+ clickable ExitName $+ hLimit exitButtonWidth $+ vLimit exitButtonHeight $+ withBorderStyle borderStyle $+ withAttr borderStyleAttr $+ border $+ withAttr baseAttr $+ center $+ withAttr textStyleAttr $+ txt labelText+ where+ isSelected = uiSelected state == exitSelectionIndex state+ borderStyle = if isSelected then unicodeBold else unicode+ borderStyleAttr =+ if isSelected+ then exitFocusedAttr+ else exitAttr+ textStyleAttr =+ if isSelected+ then focusedTitleAttr+ else mutedAttr+ labelText =+ if isSelected+ then "> Exit"+ else "Exit"++-- | Handle keyboard, mouse, and resize events.+handleEvent :: BrickEvent Name e -> EventM Name UiState ()+handleEvent event = do+ state <- get+ case event of+ VtyEvent (V.EvResize width height) ->+ put $ normalizeScroll state{uiWidth = width, uiHeight = height}+ VtyEvent (V.EvKey V.KEnter []) ->+ put (launchSelected state) >> Brick.halt+ VtyEvent (V.EvKey V.KEsc []) ->+ Brick.halt+ VtyEvent (V.EvKey (V.KChar 'q') []) ->+ Brick.halt+ VtyEvent (V.EvKey V.KLeft []) ->+ put $ moveHorizontal (-1) state+ VtyEvent (V.EvKey (V.KChar 'h') []) ->+ put $ moveHorizontal (-1) state+ VtyEvent (V.EvKey V.KRight []) ->+ put $ moveHorizontal 1 state+ VtyEvent (V.EvKey (V.KChar 'l') []) ->+ put $ moveHorizontal 1 state+ VtyEvent (V.EvKey V.KUp []) ->+ put $ moveVertical (-1) state+ VtyEvent (V.EvKey (V.KChar 'k') []) ->+ put $ moveVertical (-1) state+ VtyEvent (V.EvKey V.KDown []) ->+ put $ moveVertical 1 state+ VtyEvent (V.EvKey (V.KChar 'j') []) ->+ put $ moveVertical 1 state+ MouseDown (TileName index) V.BLeft [] _ ->+ put (launchByIndex index state) >> Brick.halt+ MouseDown ExitName V.BLeft [] _ ->+ Brick.halt+ MouseDown _ V.BScrollDown [] _ ->+ put $ scrollRows 1 state+ MouseDown _ V.BScrollUp [] _ ->+ put $ scrollRows (-1) state+ _ -> pure ()++-- | Launch the currently selected entry.+launchSelected :: UiState -> UiState+launchSelected state+ | uiSelected state == exitSelectionIndex state = state+ | otherwise = launchByIndex (uiSelected state) state++-- | Convert an entry index into the corresponding launch request.+launchByIndex :: Int -> UiState -> UiState+launchByIndex index state =+ state+ { uiSelected = boundedIndex+ , uiLaunch =+ Just+ LaunchSpec+ { launchCommand = resolvedCommand entry+ , launchWorkingDir = resolvedWorkingDir entry+ , launchShell = resolvedShell entry+ }+ }+ where+ entries = NonEmpty.toList (configEntries (uiConfig state))+ boundedIndex = clamp 0 (length entries - 1) index+ entry = entries !! boundedIndex++-- | Move the focused tile left or right.+moveHorizontal :: Int -> UiState -> UiState+moveHorizontal delta state =+ normalizeScroll $+ case uiSelected state of+ selectedIndex+ | selectedIndex == exitSelectionIndex state -> state+ | otherwise ->+ state+ { uiSelected =+ clamp 0 (entryCount state - 1) (selectedIndex + delta)+ }++-- | Move the focused tile up or down by one grid row.+moveVertical :: Int -> UiState -> UiState+moveVertical delta state+ | delta == 0 = state+ | uiSelected state == exitSelectionIndex state && delta < 0 =+ normalizeScroll state{uiSelected = max 0 (entryCount state - 1)}+ | uiSelected state == exitSelectionIndex state =+ state+ | delta > 0 =+ normalizeScroll $+ state+ { uiSelected =+ if candidate < entryCount state+ then candidate+ else exitSelectionIndex state+ }+ | otherwise =+ normalizeScroll $+ state+ { uiSelected =+ clamp 0 (entryCount state - 1) candidate+ }+ where+ metrics = gridMetrics (configLayout (uiConfig state)) (uiWidth state) (uiHeight state) (entryCount state)+ candidate = uiSelected state + delta * gridColumns metrics++-- | Scroll the viewport by a number of rows.+scrollRows :: Int -> UiState -> UiState+scrollRows delta state =+ state{uiScrollRow = clamp 0 maxScroll (uiScrollRow state + delta)}+ where+ metrics = gridMetrics (configLayout (uiConfig state)) (uiWidth state) (uiHeight state) (entryCount state)+ maxScroll = max 0 (gridTotalRows metrics - gridVisibleRows metrics)++-- | Keep the selected tile visible after movement or resize events.+normalizeScroll :: UiState -> UiState+normalizeScroll state =+ state{uiScrollRow = clamp 0 maxScroll targetScroll}+ where+ metrics = gridMetrics (configLayout (uiConfig state)) (uiWidth state) (uiHeight state) (entryCount state)+ maxScroll = max 0 (gridTotalRows metrics - gridVisibleRows metrics)+ selectedIndex = min (uiSelected state) (max 0 (entryCount state - 1))+ selectedRow = selectedIndex `div` gridColumns metrics+ targetScroll+ | selectedRow < uiScrollRow state = selectedRow+ | selectedRow >= uiScrollRow state + gridVisibleRows metrics =+ selectedRow - gridVisibleRows metrics + 1+ | otherwise = uiScrollRow state++-- | Compute grid dimensions for the current terminal size and layout.+gridMetrics :: LayoutConfig -> Int -> Int -> Int -> GridMetrics+gridMetrics layout width height totalEntries =+ GridMetrics+ { gridColumns = max 1 columns+ , gridVisibleRows = max 1 visibleRows+ , gridTotalRows = max 1 totalRows+ }+ where+ outerWidth = layoutTileWidth layout + layoutTileSpacing layout+ outerHeight = layoutTileHeight layout + layoutTileSpacing layout+ bodyHeight = max 1 (height - 2 - exitSectionHeight)+ columns = max 1 ((width + layoutTileSpacing layout) `div` max 1 outerWidth)+ visibleRows = max 1 ((bodyHeight + layoutTileSpacing layout) `div` max 1 outerHeight)+ totalRows = ceilingDiv totalEntries columns+ exitSectionHeight = exitButtonHeight + exitButtonSpacing++-- | Count the configured entries.+entryCount :: UiState -> Int+entryCount = length . NonEmpty.toList . configEntries . uiConfig++-- | Sentinel selection index used for the exit button.+exitSelectionIndex :: UiState -> Int+exitSelectionIndex = entryCount++-- | Minimum terminal height required to show one tile row and the exit button.+minimumHeight :: LayoutConfig -> Int+minimumHeight layout =+ 2 + layoutTileHeight layout + exitButtonSpacing + exitButtonHeight++-- | Divide and round up, returning a safe minimum of one.+ceilingDiv :: Int -> Int -> Int+ceilingDiv numerator denominator+ | denominator <= 0 = 1+ | numerator <= 0 = 1+ | otherwise = (numerator + denominator - 1) `div` denominator++-- | Split a list into equally sized chunks.+chunk :: Int -> [a] -> [[a]]+chunk size xs+ | size <= 0 = [xs]+ | null xs = []+ | otherwise =+ let (prefix, rest) = splitAt size xs+ in prefix : chunk size rest++-- | Truncate text to fit a tile, appending an ellipsis when needed.+truncateText :: Int -> Text -> Text+truncateText width textValue+ | width <= 0 = ""+ | T.length textValue <= width = textValue+ | width == 1 = "…"+ | otherwise = T.take (width - 1) textValue <> "…"++-- | Clamp a value to an inclusive range.+clamp :: Int -> Int -> Int -> Int+clamp lower upper value = max lower (min upper value)
+ test/Main.hs view
@@ -0,0 +1,87 @@+-- | End-to-end TUISpec coverage for @tui-launcher@.+module Main (main) where++import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import System.Directory (+ createDirectoryIfMissing,+ findExecutable,+ getTemporaryDirectory,+ )+import System.FilePath ((</>))+import Test.Tasty (TestTree, defaultMain, testGroup)+import TuiSpec++-- | Run the TUISpec test suite.+main :: IO ()+main = defaultMain tests++-- | Full test tree.+tests :: TestTree+tests =+ testGroup+ "tui-launcher"+ [launchesShellEntryFromExplicitConfig]++{- | Verify that @--config@ loads explicit entries, arrow-key navigation moves+selection, and choosing the @Shell@ entry drops into a real shell.+-}+launchesShellEntryFromExplicitConfig :: TestTree+launchesShellEntryFromExplicitConfig =+ tuiTest runOptions "launches shell from --config with arrow keys" $ \tui -> do+ (binaryPath, configPath) <- prepareConfig "tui-launcher-launch" launcherConfig+ launch tui (app binaryPath ["--config", configPath])+ waitForText tui (Nth 0 (Exact "tui-launcher"))+ waitForText tui (Nth 0 (Exact "Logs"))+ waitForText tui (Nth 0 (Exact "InteractiveShell"))+ waitForText tui (Nth 0 (Exact "Exit"))+ press tui ArrowRight+ press tui Enter+ sendLine tui "echo 'hello, world'"+ waitForText tui (Nth 0 (Exact "hello, world"))+ sendLine tui "exit"++-- | Shared TUISpec runtime options for integration tests.+runOptions :: RunOptions+runOptions =+ defaultRunOptions+ { timeoutSeconds = 15+ , terminalCols = 80+ , terminalRows = 24+ , artifactsDir = "artifacts/tui-launcher-smoke"+ }++{- | Write a config file for a test and return the launcher binary plus config+path.+-}+prepareConfig :: FilePath -> T.Text -> IO (FilePath, FilePath)+prepareConfig testName configText = do+ tempDir <- getTemporaryDirectory+ let configDir = tempDir </> testName+ configPath = configDir </> "config.toml"+ createDirectoryIfMissing True configDir+ TIO.writeFile configPath configText+ exePath <- findExecutable "tui-launcher"+ case exePath of+ Nothing -> fail "Could not find tui-launcher executable in PATH"+ Just binaryPath -> pure (binaryPath, configPath)++-- | Explicit launcher config used by the TUISpec tests.+launcherConfig :: T.Text+launcherConfig =+ T.unlines+ [ "[layout]"+ , "tile-width = 18"+ , "tile-height = 5"+ , "tile-spacing = 1"+ , ""+ , "[[entries]]"+ , "name = \"Logs\""+ , "command = \"printf 'not-a-shell\\\\n'; exec sh\""+ , "color = \"red\""+ , ""+ , "[[entries]]"+ , "name = \"InteractiveShell\""+ , "command = \"exec /bin/sh\""+ , "color = \"green\""+ ]
+ tui-launcher.cabal view
@@ -0,0 +1,114 @@+cabal-version: 3.8+name: tui-launcher+version: 0.0.1+build-type: Simple+license: MIT+license-file: LICENSE+author: tritlo+maintainer: tritlo+copyright: 2026 tritlo+synopsis: Small Brick-based terminal launcher+description:+ @tui-launcher@ is a small terminal launcher built with @brick@.+ .+ It reads a TOML config file, shows entries as a keyboard- and mouse-driven+ tile menu, and replaces itself with the selected command.+ .+ Features include:+ .+ * TOML configuration with auto-created default config+ * Per-entry @working-dir@, @shell-program@, @shell-login@, and @color@+ * Arrow-key and @hjkl@ navigation+ * Mouse selection and scrolling+ * Configurable tile width, height, and spacing++homepage: https://github.com/TharkunAB/tui-launcher+bug-reports: https://github.com/TharkunAB/tui-launcher/issues+tested-with: ghc ==9.12.2+category: Console+extra-doc-files:+ CHANGELOG.md+ README.md+ docs/screenshots/github-dark-square.png+ docs/screenshots/github-dark-vertical.png+ docs/screenshots/github-light-square.png+ docs/screenshots/github-light-vertical.png++source-repository head+ type: git+ location: https://github.com/TharkunAB/tui-launcher.git++common warnings+ ghc-options: -Wall+ default-language: GHC2021+ default-extensions:+ BlockArguments+ LambdaCase+ OverloadedStrings+ RecordWildCards+ TypeApplications++executable tui-launcher+ import: warnings+ ghc-options: -threaded+ main-is: Main.hs+ hs-source-dirs:+ app+ src++ other-modules:+ TuiLauncher.App+ TuiLauncher.Config+ TuiLauncher.Themes+ TuiLauncher.Types+ TuiLauncher.UI++ build-depends:+ base >=4.21 && <5,+ brick >=2.10 && <2.11,+ containers >=0.7 && <0.8,+ directory >=1.3 && <1.4,+ filepath >=1.5 && <1.6,+ optparse-applicative >=0.19 && <0.20,+ process >=1.6 && <1.7,+ text >=2.1 && <2.2,+ tomland >=1.3 && <1.4,+ unix >=2.8 && <2.10,+ vty >=6.5 && <6.6,+ vty-crossplatform >=0.5 && <0.6,++test-suite tui-launcher-test+ import: warnings+ ghc-options: -threaded+ type: exitcode-stdio-1.0+ main-is: Main.hs+ hs-source-dirs:+ test+ src++ build-tool-depends:+ tui-launcher:tui-launcher++ other-modules:+ TuiLauncher.App+ TuiLauncher.Config+ TuiLauncher.Themes+ TuiLauncher.Types+ TuiLauncher.UI++ build-depends:+ base >=4.21 && <5,+ brick >=2.10 && <2.11,+ containers >=0.7 && <0.8,+ directory >=1.3 && <1.4,+ filepath >=1.5 && <1.6,+ optparse-applicative >=0.19 && <0.20,+ process >=1.6 && <1.7,+ tasty >=1.5 && <1.6,+ tasty-hunit >=0.10 && <0.12,+ text >=2.1 && <2.2,+ tomland >=1.3 && <1.4,+ tuispec >=0.2 && <0.3,+ unix >=2.8 && <2.10,+ vty >=6.5 && <6.6,+ vty-crossplatform >=0.5 && <0.6,