packages feed

cutesetup-0.1.0.0: src/UI.hs

{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TemplateHaskell #-}
module UI
  ( runApp
  ) where

import Brick
import Brick.BChan (BChan, newBChan, writeBChan)
import Brick.Widgets.Border
import Brick.Widgets.Border.Style
import Brick.Widgets.Center
import Brick.Widgets.Edit (Editor, editor, renderEditor, handleEditorEvent, getEditContents, editFocusedAttr)
import Control.Concurrent (forkIO)
import Control.Exception (try, SomeException)
import Control.Monad (void, when)
import Control.Monad.IO.Class (liftIO)
import Data.List (intercalate)
import qualified Data.Set as Set
import Data.Set (Set)
import System.Process (readProcess)
import qualified Graphics.Vty as V
import qualified Graphics.Vty.CrossPlatform as VCP
import Languages
import Lens.Micro.Platform
import NixGen
import NixSearch
import System.Directory (getCurrentDirectory)
import System.Environment (lookupEnv)

-- Resource names

data Name
  = LangList
  | PkgList
  | SearchEdit
  | SearchList
  | DirEdit
  deriving (Eq, Ord, Show)

-- Custom events

data CuteEvent
  = SearchResults [NixResult]
  | SearchError String
  | GenerateDone String
  | CacheUpdateStarted Channel
  | CacheUpdateDone Channel [NixResult]
  | CacheUpdateFailed Channel String
  | CacheLoadDone Channel [NixResult]
  | CacheLoadFailed Channel String

-- App screen

data Screen
  = LangScreen    -- ^ pick a language
  | PkgScreen     -- ^ pick packages
  | SearchScreen  -- ^ nix search + custom add
  | DirScreen     -- ^ pick output directory
  | DoneScreen    -- ^ result message
  deriving (Eq, Show)

-- App state

data AppState = AppState
  { _screen         :: Screen
  , _langCursor     :: Int
  , _pkgCursor      :: Int
  , _pkgToggled     :: Set String       -- ^ toggled OFF from defaults
  , _pkgEnabled     :: Set String       -- ^ toggled ON extras
  , _pythonVenv     :: Bool             -- ^ toggle to enter virtualenv on shell hook
  , _searchEdit     :: Editor String Name
  , _searchResults  :: [NixResult]
  , _searchCursor   :: Int
  , _searchLoading  :: Bool
  , _searchError    :: Maybe String
  , _extraAttrs     :: [(String, Channel)] -- ^ user-added from search with their channel
  , _dirEdit        :: Editor String Name
  , _resultMsg      :: String
  , _arch           :: String
  , _activeChannel  :: Channel
  , _unstableCache  :: Maybe [NixResult]
  , _stableCache    :: Maybe [NixResult]
  , _cacheUpdating  :: Bool
  , _stableVersion  :: String
  } deriving (Show)

makeLensesFor [("_searchEdit", "searchEdit"), ("_dirEdit", "dirEdit")] ''AppState

-- UI Layout and Truncation Constants
langColWidth :: Int
langColWidth = 12

pkgColWidth :: Int
pkgColWidth = 30

searchAttrColWidth :: Int
searchAttrColWidth = 28

searchDescMaxLength :: Int
searchDescMaxLength = 55

-- Helpers

currentLang :: AppState -> Language
currentLang s = allLanguages !! (_langCursor s)

currentPkgs :: AppState -> [LangPackage]
currentPkgs s =
  let lang = currentLang s
      base = packagesFor lang
  in base

-- A package is "active" if (default AND not toggled off) OR explicitly enabled
isPkgActive :: AppState -> LangPackage -> Bool
isPkgActive s p
  | pkgDefault p = not (Set.member (pkgName p) (_pkgToggled s))
  | otherwise    = Set.member (pkgName p) (_pkgEnabled s)

selectedPkgNames :: AppState -> [LangPackage]
selectedPkgNames s = filter (isPkgActive s) (currentPkgs s)

targetDir :: AppState -> String
targetDir s = concat (getEditContents (_dirEdit s))

-- Colour theme

pink, lavender, mint, peach, fgDim :: AttrName
pink     = attrName "pink"
lavender = attrName "lavender"
mint     = attrName "mint"
peach    = attrName "peach"
fgDim    = attrName "fgDim"

selectedAttr, activeAttr, inactiveAttr, headerAttr, titleAttr, hintAttr :: AttrName
selectedAttr = attrName "selected"
activeAttr   = attrName "active"
inactiveAttr = attrName "inactive"
headerAttr   = attrName "header"
titleAttr    = attrName "title"
hintAttr     = attrName "hint"

rgb :: Int -> Int -> Int -> V.Color
rgb = V.rgbColor

theMap :: AttrMap
theMap = attrMap (V.white `on` V.black) $
  [ (pink,         fg (rgb 255 105 180))
  , (lavender,     fg (rgb 180 130 255))
  , (mint,         fg (rgb 100 230 180))
  , (peach,        fg (rgb 255 180 100))
  , (fgDim,        fg (rgb 120 120 140))
  , (selectedAttr, rgb 30 30 50 `on` rgb 180 130 255)
  , (activeAttr,   fg (rgb 100 230 180))
  , (inactiveAttr, fg (rgb 90 90 110))
  , (headerAttr,   V.withStyle (fg (rgb 255 105 180)) V.bold)
  , (titleAttr,    V.withStyle (fg (rgb 180 130 255)) V.bold)
  , (hintAttr,     fg (rgb 100 110 130))
  , (editFocusedAttr, rgb 20 20 40 `on` rgb 200 150 255)
  ]


-- Drawing

drawUI :: AppState -> [Widget Name]
drawUI s = case _screen s of
  LangScreen   -> [drawLangScreen s]
  PkgScreen    -> [drawPkgScreen s]
  SearchScreen -> [drawSearchScreen s]
  DirScreen    -> [drawDirScreen s]
  DoneScreen   -> [drawDoneScreen s]

-- Banner / header
drawBanner :: Widget Name
drawBanner = withAttr titleAttr $ vBox
  [ hCenter $ str "  ✿ cutesetup ✿  "
  , hCenter $ withAttr hintAttr (str "nix devshell scaffolding")
  ]

drawFooter :: String -> Widget Name
drawFooter hints = withAttr hintAttr $ hCenter $ str hints

-- Lang screen

drawLangScreen :: AppState -> Widget Name
drawLangScreen s = joinBorders $
  withBorderStyle unicode $
  border $
  vBox
  [ drawBanner
  , hBorder
  , hCenter $ withAttr headerAttr (str "Pick a language")
  , hCenter $ withAttr hintAttr (str "↑↓ navigate   Enter select   q quit")
  , hBorder
  , viewport LangList Vertical $ vBox (zipWith (drawLangRow s) [0..] allLanguages)
  , hBorder
  , drawFooter $ "  " ++ show (length allLanguages) ++ " languages available"
  ]

drawLangRow :: AppState -> Int -> Language -> Widget Name
drawLangRow s i lang =
  let sel = i == _langCursor s
      icon = langIcon lang
      name = show lang
      desc = langDescription lang
      row  = hBox
        [ str (if sel then " ▶ " else "   ")
        , withAttr peach (str icon)
        , str " "
        , withAttr (if sel then selectedAttr else titleAttr) (str (padStringRight langColWidth name))
        , str "  "
        , withAttr fgDim (str desc)
        ]
  in if sel
       then withAttr selectedAttr (padRight Max row)
       else padRight Max row

-- ── Pkg screen

drawPkgScreen :: AppState -> Widget Name
drawPkgScreen s =
  let lang    = currentLang s
      pkgs    = currentPkgs s
      nSel    = length (selectedPkgNames s)
      isPipActive = lang == Python && any (\p -> pkgName p == "python3Packages.pip") (selectedPkgNames s)
      hintStr = if isPipActive
                  then "↑↓ navigate   Space toggle   v toggle venv   s nix-search   Enter next   Esc back"
                  else "↑↓ navigate   Space toggle   s nix-search   Enter next   Esc back"
  in joinBorders $
     withBorderStyle unicode $
     border $
     vBox
     [ drawBanner
     , hBorder
     , hCenter $ withAttr headerAttr
         (str (langIcon lang ++ "  " ++ show lang ++ " packages"))
     , hCenter $ withAttr hintAttr
         (str hintStr)
     , hBorder
     , viewport PkgList Vertical $ vBox (zipWith (drawPkgRow s) [0..] pkgs)
     , drawVenvRecommendation s
     , hBorder
     , hBox
       [ withAttr mint (str ("  ✔  " ++ show nSel ++ " selected"))
       , str "   "
       , withAttr fgDim (str "s → nix search for more")
       ]
     ]

drawPkgRow :: AppState -> Int -> LangPackage -> Widget Name
drawPkgRow s i p =
  let sel     = i == _pkgCursor s
      active  = isPkgActive s p
      check   = if active then withAttr mint (str "[✔]") else withAttr inactiveAttr (str "[ ]")
      row = hBox
        [ str (if sel then " ▶ " else "   ")
        , check
        , str " "
        , withAttr (if active then activeAttr else inactiveAttr)
            (str (padStringRight pkgColWidth (pkgLabel p)))
        , str "  "
        , withAttr fgDim (str (pkgDesc p))
        ]
  in if sel
       then withAttr selectedAttr (padRight Max row)
       else padRight Max row

-- Search screen

drawSearchScreen :: AppState -> Widget Name
drawSearchScreen s =
  let extraCount = length (_extraAttrs s)
      extraNames = map fst (_extraAttrs s)
  in joinBorders $
     withBorderStyle unicode $
     border $
     vBox
     [ drawBanner
     , hBorder
     , hCenter $ withAttr headerAttr (str "❄  nix search")
     , hCenter $ withAttr hintAttr
         (str "Type to search   ↑↓ navigate   Enter add   Ctrl+s switch channel   Ctrl+u update cache   Esc back")
     , hBorder
     , hBox
       [ withAttr lavender (str "  search: ")
       , renderEditor (str . unlines) (_screen s == SearchScreen) (_searchEdit s)
       , fill ' '
       , withAttr titleAttr (str " channel: ")
       , case _activeChannel s of
           Unstable -> withAttr pink (str "[unstable]")
           Stable   -> withAttr mint (str "[stable]")
       , str "  "
       ]
     , hBorder
     , if _searchLoading s
         then hCenter (withAttr peach (str (case _searchError s of
                                              Just err -> err
                                              Nothing  -> "Loading packages cache…")))
         else case _searchError s of
           Just err -> hCenter (withAttr peach (str ("⚠️  " ++ err)))
           Nothing  -> drawSearchResults s
     , hBorder
     , hBox
       [ withAttr mint (str ("  ✔  " ++ show extraCount ++ " added:"))
       , str "  "
       , withAttr fgDim (str (intercalate ", " extraNames))
       ]
     , hBorder
     , drawFooter "  Enter → add to devshell   Esc → back to packages   Tab → continue"
     ]

drawSearchResults :: AppState -> Widget Name
drawSearchResults s =
  if null (_searchResults s)
    then hCenter $ withAttr fgDim (str "no results — try searching above")
    else viewport SearchList Vertical $
         vBox (zipWith (drawResultRow s) [0..] (_searchResults s))

drawResultRow :: AppState -> Int -> NixResult -> Widget Name
drawResultRow s i r =
  let sel     = i == _searchCursor s
      added   = any (\(attr, _) -> attr == nrAttr r) (_extraAttrs s)
      chanStr = case lookup (nrAttr r) (_extraAttrs s) of
        Just Unstable -> " [unstable]"
        Just Stable   -> " [stable]"
        Nothing       -> ""
      check   = if added then withAttr mint (str "[+]") else str "   "
      row = hBox
        [ str (if sel then " ▶ " else "   ")
        , check
        , str " "
        , withAttr lavender (str (padStringRight searchAttrColWidth (nrAttr r)))
        , withAttr hintAttr (str chanStr)
        , str "  "
        , withAttr fgDim (str (take searchDescMaxLength (nrDesc r)))
        ]
  in if sel
       then withAttr selectedAttr (padRight Max row)
       else padRight Max row

-- Dir screen

drawDirScreen :: AppState -> Widget Name
drawDirScreen s =
  joinBorders $
  withBorderStyle unicode $
  border $
  vBox
  [ drawBanner
  , hBorder
  , hCenter $ withAttr headerAttr (str " output directory")
  , hCenter $ withAttr hintAttr (str "Edit path   Enter generate   Esc back")
  , hBorder
  , hBox
    [ withAttr lavender (str "  dir: ")
    , renderEditor (str . unlines) True (_dirEdit s)
    ]
  , hBorder
  , drawSummary s
  , hBorder
  , drawFooter "  Enter → write flake.nix + .envrc"
  ]

drawSummary :: AppState -> Widget Name
drawSummary s =
  let lang  = currentLang s
      pkgs  = selectedPkgNames s
      extra = _extraAttrs s
      formatExtra (attr, chan) = attr ++ case chan of
        Unstable -> " (unstable)"
        Stable   -> " (stable)"
  in vBox
     [ hBox [ withAttr lavender (str "  language: ")
            , withAttr peach    (str (langIcon lang ++ " " ++ show lang))
            ]
     , hBox [ withAttr lavender (str "  packages: ")
            , withAttr mint     (str (intercalate ", " (map pkgLabel pkgs)))
            ]
     , if null extra then emptyWidget
       else hBox [ withAttr lavender (str "  extra:    ")
                 , withAttr mint     (str (intercalate ", " (map formatExtra extra)))
                 ]
     ]

-- Done screen

drawDoneScreen :: AppState -> Widget Name
drawDoneScreen s =
  joinBorders $
  withBorderStyle unicode $
  border $
  vBox
  [ drawBanner
  , hBorder
  , center $ vBox
    [ hCenter $ withAttr mint (str "✿ ✿ ✿  done!  ✿ ✿ ✿")
    , str ""
    , hCenter $ withAttr activeAttr (str (_resultMsg s))
    , str ""
    , hCenter $ withAttr hintAttr (str "Run:  direnv allow")
    , hCenter $ withAttr hintAttr (str "Then: nix develop")
    , str ""
    , hCenter $ withAttr peach (str "Press q or Enter to exit")
    ]
  ]

-- ─── Event handling ────────────────────────────────────────────────────────────

handleEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handleEvent _ (AppEvent ev) = handleCustomEvent ev
handleEvent chan ev = do
  s <- get
  case _screen s of
    LangScreen   -> handleLangEvent chan ev
    PkgScreen    -> handlePkgEvent chan ev
    SearchScreen -> handleSearchEvent chan ev
    DirScreen    -> handleDirEvent chan ev
    DoneScreen   -> handleDoneEvent chan ev

handleCustomEvent :: CuteEvent -> EventM Name AppState ()
handleCustomEvent (SearchResults rs) = do
  modify $ \s -> s { _searchResults = rs
                   , _searchLoading = False
                   , _searchError   = Nothing
                   , _searchCursor  = 0 }
handleCustomEvent (SearchError err) = do
  modify $ \s -> s { _searchLoading = False
                   , _searchError   = Just err }
handleCustomEvent (GenerateDone msg) = do
  modify $ \s -> s { _screen = DoneScreen, _resultMsg = msg }
handleCustomEvent (CacheUpdateStarted c) = do
  modify $ \s -> s { _cacheUpdating = True, _searchLoading = True, _searchError = Just ("⏳ Downloading " ++ show c ++ " packages list…") }
handleCustomEvent (CacheUpdateDone c pkgs) = do
  modify $ \s ->
    let nextState = case c of
          Unstable -> s { _unstableCache = Just pkgs }
          Stable   -> s { _stableCache = Just pkgs }
    in nextState { _cacheUpdating = False, _searchLoading = False, _searchError = Nothing, _searchCursor = 0 }
handleCustomEvent (CacheUpdateFailed c err) = do
  modify $ \s -> s { _cacheUpdating = False, _searchLoading = False, _searchError = Just ("Failed to download " ++ show c ++ " cache: " ++ err) }
handleCustomEvent (CacheLoadDone c pkgs) = do
  modify $ \s ->
    let nextState = case c of
          Unstable -> s { _unstableCache = Just pkgs }
          Stable   -> s { _stableCache = Just pkgs }
    in nextState { _searchLoading = False, _searchError = Nothing, _searchCursor = 0 }
handleCustomEvent (CacheLoadFailed _ err) = do
  modify $ \s -> s { _searchLoading = False, _searchError = Just err }

-- Lang screen
handleLangEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handleLangEvent _ (VtyEvent (V.EvKey V.KUp _)) = do
  modify $ \s -> s { _langCursor = max 0 (_langCursor s - 1) }
  vScrollBy (viewportScroll LangList) (-1)
handleLangEvent _ (VtyEvent (V.EvKey V.KDown _)) = do
  modify $ \s -> s { _langCursor = min (length allLanguages - 1) (_langCursor s + 1) }
  vScrollBy (viewportScroll LangList) 1
handleLangEvent _ (VtyEvent (V.EvKey V.KEnter _)) = do
  modify $ \s -> s
    { _screen     = PkgScreen
    , _pkgCursor  = 0
    , _pkgToggled = Set.empty
    , _pkgEnabled = Set.empty
    , _extraAttrs = []
    }
handleLangEvent _ (VtyEvent (V.EvKey (V.KChar 'q') _)) = halt
handleLangEvent _ _ = return ()

-- Pkg screen
handlePkgEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handlePkgEvent _ (VtyEvent (V.EvKey V.KUp _)) = do
  modify $ \s -> s { _pkgCursor = max 0 (_pkgCursor s - 1) }
  vScrollBy (viewportScroll PkgList) (-1)
handlePkgEvent _ (VtyEvent (V.EvKey V.KDown _)) = do
  modify $ \s -> s { _pkgCursor = min (length (currentPkgs s) - 1) (_pkgCursor s + 1) }
  vScrollBy (viewportScroll PkgList) 1
handlePkgEvent _ (VtyEvent (V.EvKey (V.KChar ' ') _)) = do
  s <- get
  let pkgs = currentPkgs s
      idx  = _pkgCursor s
      p    = pkgs !! idx
      name = pkgName p
  if pkgDefault p
    then modify $ \st ->
           if Set.member name (_pkgToggled st)
             then st { _pkgToggled = Set.delete name (_pkgToggled st) }
             else st { _pkgToggled = Set.insert name (_pkgToggled st) }
    else modify $ \st ->
           if Set.member name (_pkgEnabled st)
             then st { _pkgEnabled = Set.delete name (_pkgEnabled st) }
             else st { _pkgEnabled = Set.insert name (_pkgEnabled st) }
handlePkgEvent _ (VtyEvent (V.EvKey (V.KChar 'v') _)) = do
  s <- get
  let lang = currentLang s
      isPipActive = lang == Python && any (\p -> pkgName p == "python3Packages.pip") (selectedPkgNames s)
  when isPipActive $
    modify $ \st -> st { _pythonVenv = not (_pythonVenv st) }
handlePkgEvent chan (VtyEvent (V.EvKey (V.KChar 's') _)) = do
  modify $ \s -> s { _screen = SearchScreen }
  s2 <- get
  let activePkgs = case _activeChannel s2 of
        Unstable -> _unstableCache s2
        Stable   -> _stableCache s2
  case activePkgs of
    Nothing -> do
      modify $ \st -> st { _searchLoading = True, _searchError = Nothing }
      liftIO $ triggerCacheLoad chan (_stableVersion s2) (_activeChannel s2)
    Just _ -> return ()
handlePkgEvent _ (VtyEvent (V.EvKey V.KEnter _)) =
  modify $ \s -> s { _screen = DirScreen }
handlePkgEvent _ (VtyEvent (V.EvKey V.KEsc _)) =
  modify $ \s -> s { _screen = LangScreen }
handlePkgEvent _ (VtyEvent (V.EvKey (V.KChar 'q') _)) = halt
handlePkgEvent _ _ = return ()

-- Search screen
handleSearchEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handleSearchEvent _ (VtyEvent (V.EvKey V.KEsc _)) =
  modify $ \s -> s { _screen = PkgScreen }
handleSearchEvent _ (VtyEvent (V.EvKey (V.KChar '\t') [])) =
  modify $ \s -> s { _screen = DirScreen }
handleSearchEvent _ (VtyEvent (V.EvKey V.KUp _)) = do
  modify $ \s -> s { _searchCursor = max 0 (_searchCursor s - 1) }
  vScrollBy (viewportScroll SearchList) (-1)
handleSearchEvent _ (VtyEvent (V.EvKey V.KDown _)) = do
  modify $ \s -> s { _searchCursor = min (length (_searchResults s) - 1) (_searchCursor s + 1) }
  vScrollBy (viewportScroll SearchList) 1
handleSearchEvent _ (VtyEvent (V.EvKey V.KEnter _)) = do
  s <- get
  let results = _searchResults s
  when (not (null results)) $ do
    let r    = results !! _searchCursor s
        attr = nrAttr r
        isAdded = any (\(a, _) -> a == attr) (_extraAttrs s)
    if isAdded
      then modify $ \st -> st { _extraAttrs = filter (\(a, _) -> a /= attr) (_extraAttrs st) }
      else modify $ \st -> st { _extraAttrs = _extraAttrs st ++ [(attr, _activeChannel st)] }
handleSearchEvent chan (VtyEvent (V.EvKey (V.KChar 's') [V.MCtrl])) = do
  s <- get
  when (not (_cacheUpdating s)) $ do
    let nextChan = case _activeChannel s of
          Unstable -> Stable
          Stable   -> Unstable
    modify $ \st -> st { _activeChannel = nextChan, _searchResults = [], _searchCursor = 0 }
    s2 <- get
    let activePkgs = case nextChan of
          Unstable -> _unstableCache s2
          Stable   -> _stableCache s2
    case activePkgs of
      Nothing -> do
        modify $ \st -> st { _searchLoading = True, _searchError = Nothing }
        liftIO $ triggerCacheLoad chan (_stableVersion s2) nextChan
      Just pkgs -> do
        let q = concat (getEditContents (_searchEdit s2))
            results = searchLocal q pkgs
        modify $ \st -> st { _searchResults = results, _searchError = Nothing }
handleSearchEvent chan (VtyEvent (V.EvKey (V.KChar 'u') [V.MCtrl])) = do
  s <- get
  when (not (_cacheUpdating s)) $ do
    liftIO $ triggerCacheUpdate chan (_stableVersion s) (_activeChannel s)
handleSearchEvent _ ev = zoom searchEdit $ handleEditorEvent ev

-- Dir screen
handleDirEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handleDirEvent _ (VtyEvent (V.EvKey V.KEsc _)) =
  modify $ \s -> s { _screen = PkgScreen }
handleDirEvent _ (VtyEvent (V.EvKey V.KEnter _)) = do
  s <- get
  let dir   = targetDir s
      lang  = currentLang s
      pkgs  = selectedPkgNames s
      extra = _extraAttrs s
      cfg   = GenerateConfig
        { gcLang         = lang
        , gcPackages     = pkgs
        , gcExtraAttrs   = extra
        , gcTargetDir    = if null dir then "." else dir
        , gcSystemArch   = _arch s
        , gcPythonVenv   = _pythonVenv s && (lang == Python) && any (\p -> pkgName p == "python3Packages.pip") pkgs
        , gcNixpkgsStable = _stableVersion s
        }
  result <- liftIO $ generateBoth cfg
  case result of
    Right msg -> modify $ \st -> st { _screen = DoneScreen, _resultMsg = msg }
    Left err  -> modify $ \st -> st { _resultMsg = "✖ " ++ err, _screen = DoneScreen }
handleDirEvent _ ev = zoom dirEdit $ handleEditorEvent ev

-- Done screen
handleDoneEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handleDoneEvent _ (VtyEvent (V.EvKey V.KEnter _)) = halt
handleDoneEvent _ (VtyEvent (V.EvKey (V.KChar 'q') _)) = halt
handleDoneEvent _ _ = return ()

-- Cache helpers

triggerCacheLoad :: CuteChan -> String -> Channel -> IO ()
triggerCacheLoad chan version c = void $ forkIO $ do
  res <- loadCache version c
  case res of
    Right pkgs -> writeBChan chan (CacheLoadDone c pkgs)
    Left _     -> writeBChan chan (CacheLoadFailed c "Cache not found. Press Ctrl+u to download.")

triggerCacheUpdate :: CuteChan -> String -> Channel -> IO ()
triggerCacheUpdate chan version c = void $ forkIO $ do
  writeBChan chan (CacheUpdateStarted c)
  res <- updateCache version c
  case res of
    Left err -> writeBChan chan (CacheUpdateFailed c err)
    Right _  -> do
      loadRes <- loadCache version c
      case loadRes of
        Right pkgs -> writeBChan chan (CacheUpdateDone c pkgs)
        Left err   -> writeBChan chan (CacheUpdateFailed c ("Failed to load cache after download: " ++ err))

-- Initial state

initialState :: FilePath -> String -> String -> AppState
initialState cwd arch version = AppState
  { _screen        = LangScreen
  , _langCursor    = 0
  , _pkgCursor     = 0
  , _pkgToggled    = Set.empty
  , _pkgEnabled    = Set.empty
  , _pythonVenv    = True
  , _searchEdit    = editor SearchEdit (Just 1) ""
  , _searchResults = []
  , _searchCursor  = 0
  , _searchLoading = False
  , _searchError   = Nothing
  , _extraAttrs    = []
  , _dirEdit       = editor DirEdit (Just 1) cwd
  , _resultMsg     = ""
  , _arch          = arch
  , _activeChannel = Unstable
  , _unstableCache = Nothing
  , _stableCache   = Nothing
  , _cacheUpdating = False
  , _stableVersion = version
  }

-- BChan-aware search trigger

type CuteChan = BChan CuteEvent

appWithSearch :: CuteChan -> App AppState CuteEvent Name
appWithSearch chan = App
  { appDraw         = drawUI
  , appChooseCursor = showFirstCursor
  , appHandleEvent  = handleEventWithChan chan
  , appStartEvent   = return ()
  , appAttrMap      = const theMap
  }

handleEventWithChan :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()
handleEventWithChan chan ev = do
  s <- get
  -- Intercept search-screen typing to do local search
  case (_screen s, ev) of
    (SearchScreen, VtyEvent (V.EvKey k mods))
      | isTypingKey k mods -> do
          zoom searchEdit $ handleEditorEvent ev
          s2 <- get
          let q = concat (getEditContents (_searchEdit s2))
              activePkgs = case _activeChannel s2 of
                Unstable -> _unstableCache s2
                Stable   -> _stableCache s2
          case activePkgs of
            Nothing -> modify $ \st -> st { _searchResults = [], _searchError = Just "Cache not loaded. Press Ctrl+u to download." }
            Just pkgs -> do
              let results = searchLocal q pkgs
              modify $ \st -> st { _searchResults = results, _searchError = Nothing, _searchCursor = 0 }
    _ -> handleEvent chan ev
  where
    isTypingKey (V.KChar _) [] = True
    isTypingKey V.KBS      [] = True
    isTypingKey V.KDel     [] = True
    isTypingKey _          _  = False

-- Entry point

runApp :: IO ()
runApp = do
  cwd  <- getCurrentDirectory
  arch <- detectArch
  chan <- newBChan 10

  envStable <- lookupEnv "NIX_STABLE_VERSION"
  let stableVersion = maybe "nixos-25.11" id envStable

  let buildVty = VCP.mkVty V.defaultConfig
  initialVty <- buildVty

  void $ customMain initialVty buildVty (Just chan)
    (appWithSearch chan)
    (initialState cwd arch stableVersion)

detectArch :: IO String
detectArch = do
  res <- tryReadProcess "uname" ["-m"] ""
  case res of
    Left _  -> return "x86_64-linux"
    Right r ->
      let m = filter (/= '\n') r
      in return $ case m of
           "x86_64"  -> "x86_64-linux"
           "aarch64" -> "aarch64-linux"
           _         -> "x86_64-linux"

tryReadProcess :: String -> [String] -> String -> IO (Either String String)
tryReadProcess cmd args inp = do
  res <- try @SomeException (readProcess cmd args inp)
  return $ case res of
    Left e  -> Left (show e)
    Right r -> Right r

-- Utility

padStringRight :: Int -> String -> String
padStringRight n s
  | length s >= n = take n s
  | otherwise     = s ++ replicate (n - length s) ' '

drawVenvRecommendation :: AppState -> Widget Name
drawVenvRecommendation s =
  let lang = currentLang s
      isPipActive = lang == Python && any (\p -> pkgName p == "python3Packages.pip") (selectedPkgNames s)
  in if isPipActive
       then vBox
            [ hBorder
            , padLeftRight 2 $ withAttr peach $ border $ vBox
              [ hCenter $ withAttr pink (str " Highly Recommended for Pip")
              , hCenter $ str "Using pip inside a Nix environment can cause externally-managed-environment errors."
              , hCenter $ str "We highly recommend auto-creating and activating a virtual environment."
              , str ""
              , hCenter $ hBox
                [ withAttr mint (str (if _pythonVenv s then "[✔] " else "[ ] "))
                , withAttr titleAttr (str "Auto-create & enter virtualenv in flake shellHook")
                , withAttr hintAttr (str " (Press 'v' to toggle)")
                ]
              ]
            ]
       else emptyWidget