diff --git a/app/Main.hs b/app/Main.hs index 9759129..881eeeb 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -74,11 +74,13 @@ drawUI :: AppState -> [Widget ClickableName] drawUI app = case _appModal app of Nothing -> [ui] Just modalState -> case modalState of - CommentModalState {} -> [renderModal app modalState, forceAttr pageEllipsesAttr ui] - NewIssueModalState {} -> [renderNewIssueModal app modalState, forceAttr pageEllipsesAttr ui] - ZoomModalState {} -> [renderZoomModal app modalState, forceAttr pageEllipsesAttr ui] - LogModalState {} -> [renderLogModal app modalState, forceAttr pageEllipsesAttr ui] + CommentModalState {} -> [renderModal app modalState, dimmedUi] + NewIssueModalState {} -> [renderNewIssueModal app modalState, dimmedUi] + ZoomModalState {} -> [renderZoomModal app modalState, dimmedUi] + LogModalState {} -> [renderLogModal app modalState, dimmedUi] where + colorMode = fromMaybe (_appActualColorMode app) (_appCliColorMode app) + dimmedUi = updateAttrMap (const (buildAdaptiveDimmedAttrMap colorMode 0.35)) ui ui = if _appSplitLogs app then splitUI else mainUI mainUI = reportExtent MainUI $ vBox [ diff --git a/app/Sauron/Actions.hs b/app/Sauron/Actions.hs index df3508b..28665c3 100644 --- a/app/Sauron/Actions.hs +++ b/app/Sauron/Actions.hs @@ -18,7 +18,7 @@ import Control.Monad.IO.Class import qualified Data.Vector as V import GitHub import Relude -import Sauron.Actions.Util (findRepoParent, openBrowserToUrl) +import Sauron.Actions.Util import Sauron.Fetch.Branch import Sauron.Fetch.Issue import Sauron.Fetch.Job @@ -28,20 +28,17 @@ import Sauron.Fetch.Repo import Sauron.Fetch.Workflow import Sauron.HealthCheck.Job (startJobHealthCheckIfNeeded) import Sauron.HealthCheck.Workflow (startWorkflowHealthCheckIfNeeded) +import Sauron.Logging import Sauron.Types import Sauron.UI.Util (isFetchingOrFetched) import UnliftIO.Async --- import Sauron.HealthCheck.Job (startJobHealthCheckIfNeeded) - refreshSelected :: (MonadIO m) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m (Async ()) refreshSelected = fetchOnOpen -refreshOnZoom :: (MonadIO m) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m () -refreshOnZoom bc node parents = fetchOnOpenIfNecessary bc node parents >>= \case - Nothing -> return () - Just asy -> wait asy +refreshOnZoom :: (MonadIO m, SomeNodeConstraints Fixed a) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m () +refreshOnZoom bc node parents = fetchOnOpenIfNecessary bc node parents >>= wait refreshLine :: (MonadIO m) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m (Async ()) refreshLine bc (RepoNode (EntityData {_static=(owner, name), _state})) _parents = @@ -72,29 +69,44 @@ refreshVisibleLines :: ( ) => V.Vector (SomeNode Variable) -> m () refreshVisibleLines elems = do baseContext <- ask - refreshVisibleLines' baseContext [] (V.toList elems) + let cb (SomeNode node) parents = liftIO $ refreshLine baseContext node parents + forM_ (V.toList elems) $ forEachVisibleNode cb [] -refreshVisibleLines' :: MonadIO m => BaseContext -> [SomeNode Variable] -> [SomeNode Variable] -> m () -refreshVisibleLines' baseContext parents nodes = do +forEachVisibleNode :: (MonadIO m) => (SomeNode Variable -> NonEmpty (SomeNode Variable) -> IO (Async ())) -> [SomeNode Variable] -> SomeNode Variable -> m () +forEachVisibleNode cb parents someNode@(SomeNode node) = do -- Be careful not to log from the calling thread of this function, because -- this function is called in Main.hs, before the event loop starts. Logging -- writes to the Brick event chan, and it is bounded, so if we fill it up -- before starting the event loop, we can crash with an STM deadlock. - forM_ nodes $ \someNode@(SomeNode node) -> do - parentAsy <- refreshLine baseContext node (someNode :| parents) + parentAsy <- liftIO $ cb someNode (someNode :| parents) - whenM (readTVarIO (_toggled (getEntityData node))) $ void $ liftIO $ async $ do - wait parentAsy - atomically (getExistentialChildrenWrapped node) - >>= refreshVisibleLines' baseContext (someNode : parents) + whenM (readTVarIO (_toggled (getEntityData node))) $ void $ liftIO $ async $ do + wait parentAsy + atomically (getExistentialChildrenWrapped node) + >>= mapM_ (forEachVisibleNode cb (someNode : parents)) -fetchOnOpenIfNecessary :: (MonadIO m) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m (Maybe (Async ())) +fetchOnOpenIfNecessary :: (MonadIO m, SomeNodeConstraints Fixed a) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m (Async ()) fetchOnOpenIfNecessary bc node parents = do - shouldFetchOnExpand node >>= \case + maybeAsy <- shouldFetchOnExpand node >>= \case True -> (Just <$>) $ fetchOnOpen bc node parents False -> return Nothing + liftIO $ async $ do + whenJust maybeAsy wait + + let cb (SomeNode node') parents' = liftIO $ onBecameVisible bc node' parents' + atomically (getExistentialChildrenWrapped node) >>= \children' -> + liftIO $ forConcurrently_ children' (forEachVisibleNode cb (SomeNode node : toList parents)) + +onBecameVisible :: (MonadIO m) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m (Async ()) +onBecameVisible bc item@(SingleWorkflowNode (EntityData {_children})) parents = do + -- After fetching, start health checks for any running workflows + liftIO (startWorkflowHealthCheckIfNeeded bc item (SomeNode item :| toList parents)) >>= \case + Just x -> pure x + Nothing -> liftIO $ async (return ()) +onBecameVisible _ _ _ = liftIO $ async (return ()) + fetchOnOpen :: (MonadIO m) => BaseContext -> Node Variable a -> NonEmpty (SomeNode Variable) -> m (Async ()) -- Container nodes that just organize other nodes - do nothing fetchOnOpen _bc (HeadingNode _) _parents = liftIO $ async (return ()) @@ -149,16 +161,6 @@ fetchOnOpen bc item@(SingleJobNode (EntityData {_state, _static=job@(Job {jobId} fetchOnOpen _ _ _ = liftIO $ async (return ()) -withRepoDefaultBranch :: MonadIO m => TVar (Fetchable Repo) -> (Maybe Text -> m ()) -> m () -withRepoDefaultBranch = withRepoDefaultBranch' (return ()) - -withRepoDefaultBranch' :: MonadIO m => m a -> TVar (Fetchable Repo) -> (Maybe Text -> m a) -> m a -withRepoDefaultBranch' defaultValue fetchableVar action = readTVarIO fetchableVar >>= \case - Fetched (Repo {..}) -> action repoDefaultBranch - Fetching (Just (Repo {..})) -> action repoDefaultBranch - _ -> defaultValue - - -- | This should be synced up with how fetchOnOpen works shouldFetchOnExpand :: MonadIO m => Node Variable a -> m Bool shouldFetchOnExpand (HeadingNode (EntityData {_state})) = return False diff --git a/app/Sauron/Actions/Util.hs b/app/Sauron/Actions/Util.hs index 264d746..e010fdb 100644 --- a/app/Sauron/Actions/Util.hs +++ b/app/Sauron/Actions/Util.hs @@ -21,6 +21,9 @@ module Sauron.Actions.Util ( , findIssuesParent , findPullsParent , findWorkflowsParent + + , withRepoDefaultBranch + , withRepoDefaultBranch' ) where import Brick.BChan @@ -91,6 +94,15 @@ findPullsParent elems = viaNonEmpty head [x | SomeNode x@(PaginatedPullsNode _) findWorkflowsParent :: NonEmpty (SomeNode Variable) -> Maybe (Node Variable PaginatedWorkflowsT) findWorkflowsParent elems = viaNonEmpty head [x | SomeNode x@(PaginatedWorkflowsNode _) <- toList elems] +withRepoDefaultBranch :: MonadIO m => TVar (Fetchable Repo) -> (Maybe Text -> m ()) -> m () +withRepoDefaultBranch = withRepoDefaultBranch' (return ()) + +withRepoDefaultBranch' :: MonadIO m => m a -> TVar (Fetchable Repo) -> (Maybe Text -> m a) -> m a +withRepoDefaultBranch' defaultValue fetchableVar action = readTVarIO fetchableVar >>= \case + Fetched (Repo {..}) -> action repoDefaultBranch + Fetching (Just (Repo {..})) -> action repoDefaultBranch + _ -> defaultValue + requestToUrl :: GenRequest mt k a -> Text requestToUrl req = case req of Query paths queryString -> pathsToUrl paths <> formatQueryString queryString diff --git a/app/Sauron/Event.hs b/app/Sauron/Event.hs index cc77634..95af14b 100644 --- a/app/Sauron/Event.hs +++ b/app/Sauron/Event.hs @@ -7,6 +7,7 @@ module Sauron.Event (appEvent) where import Brick as B import Brick.Forms import Brick.Widgets.Edit (handleEditorEvent) +import qualified WEditorBrick.WrappingEditor as WEditorBrick import Brick.Widgets.List import Control.Monad import Control.Monad.IO.Unlift @@ -71,8 +72,10 @@ appEvent s@(_appModal -> Just modalState) e = case e of modify (appModal . _Just . submissionState .~ SubmittingCloseWithComment) liftIO $ closeWithComment s modalState _ -> do - unlessM (handleModalScrolling CommentModalContent ev) $ - zoom (appModal . _Just . commentEditor) $ handleEditorEvent (VtyEvent ev) + unlessM (handleModalScrolling CommentModalContent ev) $ do + let ed = _commentEditor modalState + ed' <- WEditorBrick.handleEditor ed ev + modify (appModal . _Just . commentEditor .~ ed') NewIssueModalState {} -> case ev of (V.EvKey V.KEsc []) -> closeModal s (V.EvKey (V.KChar 'q') [V.MCtrl]) -> closeModal s @@ -86,8 +89,10 @@ appEvent s@(_appModal -> Just modalState) e = case e of _ -> case modalState of NewIssueModalState {_newIssueFocusTitle=True} -> zoom (appModal . _Just . newIssueTitleEditor) $ handleEditorEvent (VtyEvent ev) - _ -> - zoom (appModal . _Just . newIssueBodyEditor) $ handleEditorEvent (VtyEvent ev) + _ -> do + let bodyEd = _newIssueBodyEditor modalState + bodyEd' <- WEditorBrick.handleEditor bodyEd ev + modify (appModal . _Just . newIssueBodyEditor .~ bodyEd') ZoomModalState {} -> case ev of (V.EvKey V.KEsc []) -> closeModal s (V.EvKey (V.KChar 'q') [V.MCtrl]) -> closeModal s @@ -161,6 +166,17 @@ appEvent s (MouseDown LogSplitContent V.BLeft _ _) = switchToLogPane s appEvent s (MouseDown MainPane V.BLeft _ _) = switchToMainPane s appEvent s (MouseDown LogPane V.BLeft _ _) = switchToLogPane s +-- Clickable scrollbar events +appEvent _ (MouseDown (ScrollbarClick SBHandleBefore vpName) V.BLeft _ _) = + vScrollBy (viewportScroll vpName) (-1) +appEvent _ (MouseDown (ScrollbarClick SBHandleAfter vpName) V.BLeft _ _) = + vScrollBy (viewportScroll vpName) 1 +appEvent _ (MouseDown (ScrollbarClick SBTroughBefore vpName) V.BLeft _ _) = + vScrollPage (viewportScroll vpName) Up +appEvent _ (MouseDown (ScrollbarClick SBTroughAfter vpName) V.BLeft _ _) = + vScrollPage (viewportScroll vpName) Down +appEvent _ (MouseDown (ScrollbarClick _ _) _ _ _) = return () + -- Catch-all appEvent _ _ = return () diff --git a/app/Sauron/Event/CommentModal.hs b/app/Sauron/Event/CommentModal.hs index b5ed10d..fcd51ad 100644 --- a/app/Sauron/Event/CommentModal.hs +++ b/app/Sauron/Event/CommentModal.hs @@ -12,7 +12,8 @@ module Sauron.Event.CommentModal ( import Brick as B import Brick.BChan -import Brick.Widgets.Edit (editorText, getEditContents) +import WEditor.LineWrap (breakWords, noHyphen) +import WEditorBrick.WrappingEditor (dumpEditor, newEditor) import Control.Monad.IO.Unlift import qualified Data.Text as T import Data.Time @@ -31,7 +32,7 @@ handleCommentModalEvent s (CommentSubmitted result) = case result of Right _comment -> do -- Reset submission state and clear editor modify (appModal . _Just . submissionState .~ NotSubmitting) - modify (appModal . _Just . commentEditor .~ editorText CommentEditor Nothing "") + modify (appModal . _Just . commentEditor .~ newEditor (breakWords noHyphen) CommentEditor []) -- Refresh issue comments and scroll to bottom case s ^. appModal of Just (CommentModalState _editor issue _comments isPR owner name _submissionState) -> do @@ -59,13 +60,13 @@ handleCommentModalEvent _s (CommentsRefreshed comments) = do handleCommentModalEvent _s (OpenCommentModal issue comments isPR owner name) = do -- Open the comment modal with fresh comments and scroll to bottom - let editor = editorText CommentEditor Nothing "" + let editor = newEditor (breakWords noHyphen) CommentEditor [] modify (appModal ?~ CommentModalState editor issue comments isPR owner name NotSubmitting) vScrollToEnd (viewportScroll CommentModalContent) submitComment :: AppState -> ModalState Fixed -> IO () submitComment s (CommentModalState editor issue _comments _isPR owner name _submissionState) = do - let commentText = T.unlines $ getEditContents editor + let commentText = T.intercalate "\n" $ map toText $ dumpEditor editor unless (T.null $ T.strip commentText) $ do let baseContext = s ^. appBaseContext let issueNum = case issueNumber issue of IssueNumber n -> n @@ -78,7 +79,7 @@ submitComment _ _ = return () -- ZoomModalState doesn't support comments closeWithComment :: AppState -> ModalState Fixed -> IO () closeWithComment s (CommentModalState editor issue _comments _isPR owner name _submissionState) = do - let commentText = T.unlines $ getEditContents editor + let commentText = T.intercalate "\n" $ map toText $ dumpEditor editor let baseContext = s ^. appBaseContext let issueNum = case issueNumber issue of IssueNumber n -> n void $ async $ do diff --git a/app/Sauron/Event/NewIssueModal.hs b/app/Sauron/Event/NewIssueModal.hs index 99dc3ba..d518a94 100644 --- a/app/Sauron/Event/NewIssueModal.hs +++ b/app/Sauron/Event/NewIssueModal.hs @@ -17,6 +17,8 @@ import Sauron.Event.Helpers (withFixedElemAndParents) import qualified Sauron.Mutations.Issue as Issue import Sauron.Types import UnliftIO.Async +import WEditor.LineWrap (breakWords, noHyphen) +import WEditorBrick.WrappingEditor (dumpEditor, newEditor) handleNewIssueModalEvent :: AppState -> NewIssueModalEvent -> EventM ClickableName AppState () @@ -36,7 +38,7 @@ submitNewIssue :: AppState -> ModalState Fixed -> IO () submitNewIssue s (NewIssueModalState {..}) = do let titleText = T.strip $ T.unlines $ getEditContents _newIssueTitleEditor unless (T.null titleText) $ do - let bodyText = T.unlines $ getEditContents _newIssueBodyEditor + let bodyText = T.unlines $ map toText $ dumpEditor _newIssueBodyEditor let baseContext = s ^. appBaseContext void $ async $ do result <- Issue.createNewIssue baseContext _newIssueRepoOwner _newIssueRepoName titleText bodyText @@ -48,4 +50,4 @@ openNewIssueModal owner name = modify (appModal ?~ NewIssueModalState titleEditor bodyEditor owner name NotSubmitting True) where titleEditor = editorText NewIssueTitleEditor (Just 1) "" - bodyEditor = editorText NewIssueBodyEditor Nothing "" + bodyEditor = newEditor (breakWords noHyphen) NewIssueBodyEditor [] diff --git a/app/Sauron/HealthCheck/Workflow.hs b/app/Sauron/HealthCheck/Workflow.hs index 1bef0b0..5ab1363 100644 --- a/app/Sauron/HealthCheck/Workflow.hs +++ b/app/Sauron/HealthCheck/Workflow.hs @@ -40,44 +40,48 @@ startWorkflowHealthCheckIfNeeded :: -> Node Variable 'SingleWorkflowT -> NonEmpty (SomeNode Variable) -> IO (Maybe (Async ())) -startWorkflowHealthCheckIfNeeded baseContext node@(SingleWorkflowNode (EntityData {_static=workflowRun, ..})) parents = do - case findRepoParent parents of - Just (RepoNode (EntityData {_static=(owner, name)})) | hasRunningWorkflow workflowRun -> do - readTVarIO _healthCheckThread >>= \case - Nothing -> do - log baseContext LevelInfo [i|Starting health check thread for workflow: #{untagName $ workflowRunName workflowRun} \##{workflowRunRunNumber workflowRun} (period: #{workflowHealthCheckPeriodUs}us)|] Nothing - newThread <- async $ runWorkflowHealthCheckLoop baseContext owner name node - atomically $ writeTVar _healthCheckThread (Just (newThread, workflowHealthCheckPeriodUs)) - return (Just newThread) - Just (thread, _) -> return (Just thread) +startWorkflowHealthCheckIfNeeded baseContext node@(SingleWorkflowNode (EntityData {_static=workflowRun, _ident=nodeIdent, ..})) parents = do + case (findRepoParent parents, findWorkflowsParent parents) of + (Just (RepoNode (EntityData {_static=(owner, name)})), Just (PaginatedWorkflowsNode (EntityData {_children=workflowsChildren}))) + | isRunningWorkflow workflowRun -> do + info' baseContext [i|Considering whether to start workflow healthcheck thread for run: #{workflowRun}|] + readTVarIO _healthCheckThread >>= \case + Nothing -> do + log baseContext LevelInfo [i|Starting health check thread for workflow: #{untagName $ workflowRunName workflowRun} \##{workflowRunRunNumber workflowRun} (period: #{workflowHealthCheckPeriodUs}us)|] Nothing + newThread <- async $ runWorkflowHealthCheckLoop baseContext owner name node workflowsChildren nodeIdent + atomically $ writeTVar _healthCheckThread (Just (newThread, workflowHealthCheckPeriodUs)) + return (Just newThread) + Just (thread, _) -> return (Just thread) _ -> return Nothing where - runWorkflowHealthCheckLoop :: BaseContext -> Name Owner -> Name Repo -> Node Variable 'SingleWorkflowT -> IO () - runWorkflowHealthCheckLoop bc owner name (SingleWorkflowNode (EntityData {_static=staticWorkflowRun})) = + runWorkflowHealthCheckLoop :: BaseContext -> Name Owner -> Name Repo -> Node Variable 'SingleWorkflowT -> TVar [Node Variable 'SingleWorkflowT] -> Int -> IO () + runWorkflowHealthCheckLoop bc owner name (SingleWorkflowNode (EntityData {_static=staticWorkflowRun})) workflowsChildren nodeIdent' = flip runReaderT bc $ handleAny (\e -> putStrLn [i|Workflow health check thread crashed: #{e}|]) $ - fix $ \loop -> do + fix $ \loop -> fetchWorkflowJobs owner name (workflowRunWorkflowRunId staticWorkflowRun) node >>= \case - Left _err -> checkWorkflowForDoneness bc owner name (workflowRunWorkflowRunId staticWorkflowRun) loop + Left _err -> + checkWorkflowForDoneness bc owner name (workflowRunWorkflowRunId staticWorkflowRun) loop workflowsChildren nodeIdent' Right jobs - | all isJobCompleted jobs -> checkWorkflowForDoneness bc owner name (workflowRunWorkflowRunId staticWorkflowRun) loop + | all isJobCompleted jobs -> + checkWorkflowForDoneness bc owner name (workflowRunWorkflowRunId staticWorkflowRun) loop workflowsChildren nodeIdent' | otherwise -> do -- Save an API call by not checking the workflow for doneness. Just sleep and loop. threadDelay workflowHealthCheckPeriodUs loop - checkWorkflowForDoneness bc owner name workflowRunId loop = + checkWorkflowForDoneness bc owner name workflowRunId loop workflowsChildren nodeIdent' = do withGithubApiSemaphore (githubWithLogging (workflowRunR owner name workflowRunId)) >>= \case Left err -> warn' bc [i|(#{untagName owner}/#{untagName name}) Couldn't fetch workflow run #{workflowRunId}: #{err}|] - Right response -> - case hasRunningWorkflow response of - True -> do - threadDelay workflowHealthCheckPeriodUs - loop - False -> do - -- Stop ourselves by clearing the thread reference and returning - atomically $ writeTVar _healthCheckThread Nothing - return () + Right workflowRun' + | isRunningWorkflow workflowRun' -> threadDelay workflowHealthCheckPeriodUs >> loop + | otherwise -> liftIO $ atomically $ do + -- Update the parent's _children, replacing our node with updated _static + modifyTVar' workflowsChildren $ map $ \child@(SingleWorkflowNode childEd) -> + if _ident childEd == nodeIdent' + then SingleWorkflowNode (childEd { _static = workflowRun' }) + else child + writeTVar _healthCheckThread Nothing - hasRunningWorkflow :: WorkflowRun -> Bool - hasRunningWorkflow wr = not $ isWorkflowCompleted $ fromMaybe (workflowRunStatus wr) (workflowRunConclusion wr) + isRunningWorkflow :: WorkflowRun -> Bool + isRunningWorkflow wr = not $ isWorkflowCompleted $ fromMaybe (workflowRunStatus wr) (workflowRunConclusion wr) diff --git a/app/Sauron/Types.hs b/app/Sauron/Types.hs index 51698cf..28ad053 100644 --- a/app/Sauron/Types.hs +++ b/app/Sauron/Types.hs @@ -35,6 +35,7 @@ import Network.HTTP.Client (Manager) import Relude import qualified Text.Show import UnliftIO.Async +import WEditorBrick.WrappingEditor (WrappingEditor) -- * ListDrawable typeclass @@ -229,13 +230,15 @@ type family NodeChildType f a where -- * Existential wrapper +type SomeNodeConstraints f a = ( + Show (Node f a) + , Eq (Node Fixed a) + , Eq (NodeState a) + , Typeable a + ) + data SomeNode f where - SomeNode :: ( - Show (Node f a) - , Eq (Node Fixed a) - , Eq (NodeState a) - , Typeable a - ) => { unSomeNode :: Node f a } -> SomeNode f + SomeNode :: SomeNodeConstraints f a => { unSomeNode :: Node f a } -> SomeNode f instance Eq (SomeNode Fixed) where (SomeNode (x :: a)) == (SomeNode y) = case cast y of @@ -383,6 +386,7 @@ data ClickableName = | LogPane | NewIssueTitleEditor | NewIssueBodyEditor + | ScrollbarClick ClickableScrollbarElement ClickableName deriving (Show, Ord, Eq) data Variable (x :: Type) @@ -500,7 +504,7 @@ data SubmissionState = -- TODO: break these into individual types data ModalState f = CommentModalState { - _commentEditor :: Editor Text ClickableName + _commentEditor :: WrappingEditor Char ClickableName , _commentIssue :: Issue , _commentIssueComments :: V.Vector (Either IssueEvent IssueComment) , _issueIsPR :: Bool @@ -513,7 +517,7 @@ data ModalState f = } | NewIssueModalState { _newIssueTitleEditor :: Editor Text ClickableName - , _newIssueBodyEditor :: Editor Text ClickableName + , _newIssueBodyEditor :: WrappingEditor Char ClickableName , _newIssueRepoOwner :: Name Owner , _newIssueRepoName :: Name Repo , _newIssueSubmissionState :: SubmissionState diff --git a/app/Sauron/UI/AttrMap.hs b/app/Sauron/UI/AttrMap.hs index dfe378b..aca3956 100644 --- a/app/Sauron/UI/AttrMap.hs +++ b/app/Sauron/UI/AttrMap.hs @@ -10,6 +10,7 @@ import Brick.Widgets.ProgressBar import Brick.Widgets.Skylighting (attrMappingsForStyle) import qualified Graphics.Vty as V import Relude hiding (on) +import Sauron.UI.AttrMap.Dim import qualified Skylighting.Styles as Sky import qualified Skylighting.Types as SkyTypes @@ -18,7 +19,16 @@ mkAttrName :: String -> AttrName mkAttrName = attrName buildAdaptiveAttrMap :: V.ColorMode -> AttrMap -buildAdaptiveAttrMap colorMode = attrMap V.defAttr ([ +buildAdaptiveAttrMap colorMode = attrMap V.defAttr (attrToColor colorMode) + +buildAdaptiveDimmedAttrMap :: V.ColorMode -> DimAmount -> AttrMap +buildAdaptiveDimmedAttrMap colorMode amount = attrMap dimmedDefault (dimAttrMappings amount (attrToColor colorMode)) + where + dimmedDefault = V.defAttr `V.withForeColor` dimColor amount (V.RGBColor 229 229 229) + + +attrToColor :: V.ColorMode -> [(AttrName, V.Attr)] +attrToColor colorMode = ([ -- Statuses (iconAttr, fg V.white) , (normalAttr, fg V.white) diff --git a/app/Sauron/UI/AttrMap/Dim.hs b/app/Sauron/UI/AttrMap/Dim.hs new file mode 100644 index 0000000..b00321d --- /dev/null +++ b/app/Sauron/UI/AttrMap/Dim.hs @@ -0,0 +1,123 @@ +module Sauron.UI.AttrMap.Dim ( + dimAttrMappings + , dimColor + , DimAmount + ) where + +import Brick (AttrName) +import qualified Graphics.Vty as V +import Relude + + +-- | Dim amount from 0.0 (unchanged) to 1.0 (fully black). +type DimAmount = Double + +-- | Dim all colors in an attribute mapping list by the given amount. +dimAttrMappings :: DimAmount -> [(AttrName, V.Attr)] -> [(AttrName, V.Attr)] +dimAttrMappings amount = map (second (dimAttr amount)) + +dimAttr :: DimAmount -> V.Attr -> V.Attr +dimAttr amount attr = attr { + V.attrForeColor = dimMaybeColor amount (V.attrForeColor attr) + , V.attrBackColor = dimMaybeColor amount (V.attrBackColor attr) + } + +dimMaybeColor :: DimAmount -> V.MaybeDefault V.Color -> V.MaybeDefault V.Color +dimMaybeColor amount (V.SetTo c) = V.SetTo (dimColor amount c) +dimMaybeColor _ x = x + +dimColor :: DimAmount -> V.Color -> V.Color +dimColor amount (V.RGBColor r g b) = V.RGBColor (d r) (d g) (d b) + where d = dimByte (1.0 - amount) +dimColor amount (V.Color240 idx) = + let (r, g, b) = color240ToRGB idx + in V.Color240 (rgbToColor240 (d r) (d g) (d b)) + where d = dimByte (1.0 - amount) +dimColor amount (V.ISOColor idx) = + let (r, g, b) = isoColorToRGB idx + in V.RGBColor (d r) (d g) (d b) + where d = dimByte (1.0 - amount) + +dimByte :: Double -> Word8 -> Word8 +dimByte factor x = + let result = round (fromIntegral x * factor) :: Int + in fromIntegral (max 0 (min 255 result)) + +-- * ISOColor (ANSI 16) to approximate RGB + +-- | Convert an ANSI 16-color index to approximate RGB values (xterm defaults). +isoColorToRGB :: Word8 -> (Word8, Word8, Word8) +isoColorToRGB 0 = (0, 0, 0) -- black +isoColorToRGB 1 = (205, 0, 0) -- red +isoColorToRGB 2 = (0, 205, 0) -- green +isoColorToRGB 3 = (205, 205, 0) -- yellow +isoColorToRGB 4 = (0, 0, 238) -- blue +isoColorToRGB 5 = (205, 0, 205) -- magenta +isoColorToRGB 6 = (0, 205, 205) -- cyan +isoColorToRGB 7 = (229, 229, 229) -- white +isoColorToRGB 8 = (127, 127, 127) -- bright black +isoColorToRGB 9 = (255, 0, 0) -- bright red +isoColorToRGB 10 = (0, 255, 0) -- bright green +isoColorToRGB 11 = (255, 255, 0) -- bright yellow +isoColorToRGB 12 = (92, 92, 255) -- bright blue +isoColorToRGB 13 = (255, 0, 255) -- bright magenta +isoColorToRGB 14 = (0, 255, 255) -- bright cyan +isoColorToRGB 15 = (255, 255, 255) -- bright white +isoColorToRGB _ = (255, 255, 255) -- fallback + +-- * Color240 palette conversions + +-- | Convert a Color240 palette index (0-239) to RGB. +-- Indices 0-215 are a 6x6x6 color cube, 216-239 are a grayscale ramp. +color240ToRGB :: Word8 -> (Word8, Word8, Word8) +color240ToRGB idx + | idx < 216 = + let r = idx `div` 36 + g = (idx `mod` 36) `div` 6 + b = idx `mod` 6 + in (cubeVal r, cubeVal g, cubeVal b) + | otherwise = + let gray = fromIntegral (8 + 10 * fromIntegral (idx - 216) :: Int) + in (gray, gray, gray) + +-- | Convert a 6x6x6 cube component index (0-5) to an RGB byte value. +cubeVal :: Word8 -> Word8 +cubeVal 0 = 0 +cubeVal n = fromIntegral (55 + 40 * fromIntegral n :: Int) + +-- | Convert an RGB triple to the nearest Color240 palette index, +-- choosing whichever is closer between the 6x6x6 cube and the grayscale ramp. +rgbToColor240 :: Word8 -> Word8 -> Word8 -> Word8 +rgbToColor240 r g b = + let -- Nearest 6x6x6 cube match + ri = nearestCubeIdx r + gi = nearestCubeIdx g + bi = nearestCubeIdx b + cubeColor = (cubeVal ri, cubeVal gi, cubeVal bi) + cubeErr = colorDistSq (r, g, b) cubeColor + -- Nearest grayscale ramp match + avg = (fromIntegral r + fromIntegral g + fromIntegral b) `div` 3 :: Int + grayStep = max (0 :: Word8) (min 23 (round ((fromIntegral avg - 8 :: Double) / 10))) + grayLevel = (8 + 10 * grayStep) :: Word8 + grayErr = colorDistSq (r, g, b) (grayLevel, grayLevel, grayLevel) + in if grayErr < cubeErr + then grayStep + 216 + else 36 * ri + 6 * gi + bi + +-- | Find the nearest 6x6x6 cube component index (0-5) for a byte value. +nearestCubeIdx :: Word8 -> Word8 +nearestCubeIdx v + | v < 48 = 0 + | v < 115 = 1 + | v < 155 = 2 + | v < 195 = 3 + | v < 235 = 4 + | otherwise = 5 + +-- | Squared Euclidean distance between two RGB colors. +colorDistSq :: (Word8, Word8, Word8) -> (Word8, Word8, Word8) -> Int +colorDistSq (r1, g1, b1) (r2, g2, b2) = + let dr = fromIntegral r1 - fromIntegral r2 :: Int + dg = fromIntegral g1 - fromIntegral g2 :: Int + db = fromIntegral b1 - fromIntegral b2 :: Int + in dr*dr + dg*dg + db*db diff --git a/app/Sauron/UI/Modals/CommentModal.hs b/app/Sauron/UI/Modals/CommentModal.hs index ebd9f86..6d838a6 100644 --- a/app/Sauron/UI/Modals/CommentModal.hs +++ b/app/Sauron/UI/Modals/CommentModal.hs @@ -5,16 +5,17 @@ module Sauron.UI.Modals.CommentModal ( import Brick import Brick.Widgets.Border import Brick.Widgets.Center -import Brick.Widgets.Edit (Editor, getEditContents, renderEditor) import Data.String.Interpolate import qualified Data.Text as T import GitHub +import qualified Graphics.Vty as V import Lens.Micro import Relude import Sauron.Types import Sauron.UI.AttrMap import Sauron.UI.Issue (issueInner, maxCommentWidth) -import Sauron.UI.Markdown (markdownToWidgetsWithWidth) +import Sauron.UI.Modals.NewIssueModal (renderBodyEditor) +import WEditorBrick.WrappingEditor (WrappingEditor, dumpEditor) renderModal :: AppState -> ModalState Fixed -> Widget ClickableName @@ -25,79 +26,75 @@ renderModal appState (CommentModalState {_commentIssue=issue@(Issue {issueNumber , hBorder -- Scrollable content area with issue and comments - , padBottom Max $ withVScrollBars OnRight $ withVScrollBarHandles $ viewport CommentModalContent Vertical $ - hLimit maxCommentWidth $ vBox [ - issueInner (appState ^. appNow) issue _commentIssueComments - , hBorder - , str " " - , renderCommentEditor _commentEditor - ] + , withClickableVScrollBars ScrollbarClick $ withVScrollBars OnRight $ withVScrollBarHandles $ fitViewport CommentModalContent + issueWidget + (2 + editorLines + 3) + (vBox [ + issueWidget + , hBorder + , str " " + , renderBodyEditor appState True modalWidth editorLines _commentEditor + ]) , hBorder , buttonSection _commentEditor issue _submissionState ] & border & withAttr normalAttr - & hLimit (maxCommentWidth + 4) - & vLimitPercent 90 + & hLimit modalWidth + & vLimitPercent 80 & centerLayer where typ :: Text typ = if _issueIsPR then "Pull Request" else "Issue" modalTitle = [i|Comment on #{typ} \##{num}|] -renderModal _ _ = str "Invalid modal state for CommentModal" -- This should never happen -renderCommentEditor :: Editor Text ClickableName -> Widget ClickableName -renderCommentEditor editor = - vLimit 12 $ vBox [ - hBox [ - -- Left: Editor - vBox [ - withAttr italicText $ str "Write your comment:" - , padAll 1 $ - vLimit 8 $ - hLimit 60 $ - withAttr normalAttr $ - renderEditor (str . toString . T.unlines) True editor - ] - , vBorder - -- Right: Preview - , vBox [ - withAttr italicText $ str "Preview:" - , padAll 1 $ - hLimit 50 $ - vLimit 8 $ - if T.null text - then withAttr italicText $ str "(preview will appear here)" - else markdownToWidgetsWithWidth 48 text - ] - ] - ] - where - text = T.unlines $ getEditContents editor + modalWidth = case _appMainUiExtent appState of + Nothing -> maxCommentWidth + 4 + Just (Extent {extentSize=(w, _h)}) -> round ((0.8 :: Double) * fromIntegral w) + + issueWidget = hLimit maxCommentWidth $ issueInner (appState ^. appNow) issue _commentIssueComments + + bodyLineCount = length (dumpEditor _commentEditor) + editorLines = max 10 (min bodyLineCount 30) +renderModal _ _ = str "Invalid modal state for CommentModal" -- This should never happen -buttonSection :: Editor Text ClickableName -> Issue -> SubmissionState -> Widget ClickableName +buttonSection :: WrappingEditor Char ClickableName -> Issue -> SubmissionState -> Widget ClickableName buttonSection editor _issue submissionState' = padLeft Max $ hBox [ - border $ hBox [ + border $ hBox [ + str " " + , withAttr hotkeyMessageAttr $ str buttonText + , str " [" + , withAttr hotkeyAttr $ str "Alt+Shift+Enter" + , str "] " + , if submissionState' == SubmittingCloseWithComment then str " [submitting...]" else str "" + ] + , str " " + , border $ hBox [ str " " - , withAttr hotkeyMessageAttr $ str buttonText + , withAttr (if hasText && submissionState' == NotSubmitting then hotkeyMessageAttr else disabledHotkeyMessageAttr) $ str "Comment" , str " [" - , withAttr hotkeyAttr $ str "Alt+Shift+Enter" + , withAttr (if hasText && submissionState' == NotSubmitting then hotkeyAttr else disabledHotkeyAttr) $ str "Alt+Enter" , str "] " - , if submissionState' == SubmittingCloseWithComment then str " [submitting...]" else str "" + , if submissionState' == SubmittingComment then str " [submitting...]" else str "" ] - , str " " - , border $ hBox [ - str " " - , withAttr (if hasText && submissionState' == NotSubmitting then hotkeyMessageAttr else disabledHotkeyMessageAttr) $ str "Comment" - , str " [" - , withAttr (if hasText && submissionState' == NotSubmitting then hotkeyAttr else disabledHotkeyAttr) $ str "Alt+Enter" - , str "] " - , if submissionState' == SubmittingComment then str " [submitting...]" else str "" - ] ] where - text = T.unlines $ getEditContents editor + text = T.intercalate "\n" $ map toText $ dumpEditor editor hasText = not $ T.null $ T.strip text buttonText = if hasText then "Close with comment" else "Close issue" + +-- | A viewport that shrinks to fit its content instead of growing greedily. +-- Takes a measurement widget (must not contain named widgets) and a count of +-- extra rows for the remaining content, to avoid rendering named widgets twice. +fitViewport :: (Ord n, Show n) => n -> Widget n -> Int -> Widget n -> Widget n +fitViewport vpName measureWidget extraRows content = Widget Greedy Greedy $ do + c <- getContext + let availH = c ^. availHeightL + availW = c ^. availWidthL + measureResult <- render (hLimit availW measureWidget) + let measureH = V.imageHeight (measureResult ^. imageL) + contentH = measureH + extraRows + targetH = min contentH availH + render (vLimit targetH (viewport vpName Vertical content)) diff --git a/app/Sauron/UI/Modals/NewIssueModal.hs b/app/Sauron/UI/Modals/NewIssueModal.hs index 54a0528..7579a46 100644 --- a/app/Sauron/UI/Modals/NewIssueModal.hs +++ b/app/Sauron/UI/Modals/NewIssueModal.hs @@ -1,5 +1,6 @@ module Sauron.UI.Modals.NewIssueModal ( - renderNewIssueModal, + renderNewIssueModal + , renderBodyEditor ) where import Brick @@ -14,6 +15,8 @@ import Sauron.Types import Sauron.UI.AttrMap import Sauron.UI.Issue (maxCommentWidth) import Sauron.UI.Markdown (markdownToWidgetsWithWidth) +import WEditorBrick.WrappingEditor (WrappingEditor, dumpEditor) +import qualified WEditorBrick.WrappingEditor as WEditorBrick renderNewIssueModal :: AppState -> ModalState Fixed -> Widget ClickableName @@ -48,21 +51,21 @@ renderNewIssueModal app (NewIssueModalState {..}) = Nothing -> maxCommentWidth + 4 Just (Extent {extentSize=(w, _h)}) -> round ((0.8 :: Double) * fromIntegral w) - bodyLineCount = length (getEditContents _newIssueBodyEditor) + bodyLineCount = length (dumpEditor _newIssueBodyEditor) editorLines = max 10 (min bodyLineCount 30) renderNewIssueModal _ _ = str "Invalid modal state for NewIssueModal" -renderBodyEditor :: AppState -> Bool -> Int -> Int -> Editor Text ClickableName -> Widget ClickableName +renderBodyEditor :: AppState -> Bool -> Int -> Int -> WrappingEditor Char ClickableName -> Widget ClickableName renderBodyEditor (AppState {}) focused modalWidth editorHeight editor = vLimit (editorHeight + 3) $ hBox [ -- Left: Editor vBox [ - withAttr (if focused then boldText else italicText) $ str "Body" + withAttr (if focused then boldText else italicText) $ str "Write" , padAll 1 $ vLimit editorHeight $ hLimit sectionWidth $ withAttr normalAttr $ - renderEditor (str . toString . T.unlines) focused editor + WEditorBrick.renderEditor focused editor ] , vBorder -- Right: Preview @@ -78,7 +81,7 @@ renderBodyEditor (AppState {}) focused modalWidth editorHeight editor = where sectionWidth = (modalWidth - 4) `div` 2 - text = T.intercalate "\n" $ getEditContents editor + text = T.intercalate "\n" $ map toText $ dumpEditor editor newIssueButtonSection :: Editor Text ClickableName -> SubmissionState -> Widget ClickableName newIssueButtonSection titleEditor submissionState' = diff --git a/package.yaml b/package.yaml index 9fda571..b7129f7 100644 --- a/package.yaml +++ b/package.yaml @@ -97,6 +97,8 @@ executables: - microlens-th - optparse-applicative - safe-exceptions + - WEditor + - WEditorBrick - yaml tests: diff --git a/sauron.cabal b/sauron.cabal index cd47f3f..90f4b58 100644 --- a/sauron.cabal +++ b/sauron.cabal @@ -72,6 +72,7 @@ executable sauron Sauron.UI Sauron.UI.AnsiUtil Sauron.UI.AttrMap + Sauron.UI.AttrMap.Dim Sauron.UI.Border Sauron.UI.BottomBar Sauron.UI.Branch @@ -119,7 +120,9 @@ executable sauron ViewPatterns ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N -O2 -W build-depends: - aeson + WEditor + , WEditorBrick + , aeson , ansi-terminal , base >=4.7 && <5 , brick diff --git a/stack.yaml b/stack.yaml index 4a7beec..4f661e2 100644 --- a/stack.yaml +++ b/stack.yaml @@ -35,3 +35,9 @@ extra-deps: # https://github.com/input-output-hk/haskell.nix/issues/2312 - git: https://github.com/thomasjm/vty-crossplatform.git commit: 030ceab629d5c888a4a890e2aa7f1e0e6eda0578 + +- git: https://github.com/codedownio/wrapping-editor.git + commit: 397eaf5a4ad229b3db5aa007d6f7c33d2b906417 + subdirs: + - cabal/WEditor + - cabal/WEditorBrick diff --git a/stack.yaml.lock b/stack.yaml.lock index 362690f..8e6c9bc 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -51,6 +51,32 @@ packages: original: commit: 030ceab629d5c888a4a890e2aa7f1e0e6eda0578 git: https://github.com/thomasjm/vty-crossplatform.git +- completed: + commit: 397eaf5a4ad229b3db5aa007d6f7c33d2b906417 + git: https://github.com/codedownio/wrapping-editor.git + name: WEditor + pantry-tree: + sha256: 82052281dffdbf1b78d7306eb566888dd27f166408ab274bf441a90bd1ec6269 + size: 3411 + subdir: cabal/WEditor + version: 0.2.1.2 + original: + commit: 397eaf5a4ad229b3db5aa007d6f7c33d2b906417 + git: https://github.com/codedownio/wrapping-editor.git + subdir: cabal/WEditor +- completed: + commit: 397eaf5a4ad229b3db5aa007d6f7c33d2b906417 + git: https://github.com/codedownio/wrapping-editor.git + name: WEditorBrick + pantry-tree: + sha256: b68d35f13ba0f03fbf38be1c3251a03b7de558e8d6afe7e08bca48fce429bc59 + size: 345 + subdir: cabal/WEditorBrick + version: 0.2.0.2 + original: + commit: 397eaf5a4ad229b3db5aa007d6f7c33d2b906417 + git: https://github.com/codedownio/wrapping-editor.git + subdir: cabal/WEditorBrick snapshots: - completed: sha256: ff8bf94902277a37e41b5e037ac3cb49add646e70ec613c550af4321370c059c