restman-0.7.2.2: src/Lib.hs
{-# language OverloadedStrings #-}
{-# language RecordWildCards #-}
{-|
Description: RESTman as a library
For the data inclined, I recommend starting with 'AppS', which is all the internal RESTman state.
For the logic inclined, I recommen starting with 'handleEvent', which is the main way the state is
manipulated over time. The application handles command-line parsing and then calls into
'someFunc', which we never renamed from the stack template at it is responsible for building the
record of function required for a Bick application, also a reasonable starting place.
-}
module Lib
( chooseCursor
, cleanPayload
, draw
, focusAttr
, focusStyle
, growWith
, handleEvent
, doRequest
, lbsToText
, moveFocusNext
, moveFocusPrev
, noSyntaxFound
, splitExtraSpace
, syntaxHighlightResponse
) where
-- base
import Data.Bits (xor)
import Data.Foldable (for_)
import Data.List (nub, unsnoc)
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
import Data.Void (Void, absurd)
-- brick
import Brick.Main
( hScrollBy
, halt
, lookupExtent
, showCursorNamed
, suspendAndResume
, vScrollBy
, vScrollPage
, viewportScroll
)
import Brick.Types
( BrickEvent(AppEvent, MouseDown, MouseUp, VtyEvent)
, CursorLocation
, Direction(Down, Up)
, EventM
, Extent
, HScrollBarOrientation(OnBottom)
, Result(image)
, Size(Fixed)
, VScrollBarOrientation(OnRight)
, ViewportType(Both)
, Widget(Widget, render)
, attrL
, emptyResult
, extentSize
, extentUpperLeft
, getContext
, imageL
, nestEventM
)
import Brick.Widgets.Border (borderWithLabel)
import Brick.Widgets.Border.Style (unicodeBold)
import Brick.Widgets.Core
( fill
, hBox
, hLimit
, modifyDefAttr
, padLeftRight
, raw
, reportExtent
, str
, textWidth
, translateBy
, txt
, vBox
, vLimit
, vLimitPercent
, viewport
, withBorderStyle
, withHScrollBars
, withVScrollBars
)
import Brick.Widgets.Edit
( Editor
, editContentsL
, editorText
, getEditContents
, handleEditorEvent
, renderEditor
)
import Brick.Widgets.List
( List
, handleListEvent
, listElements
, listItemHeight
, listSelectedElement
, listSelectedL
, renderList
)
import qualified Brick.Widgets.List as BrickList
import Brick.Widgets.Table (renderTable, rowBorders, surroundingBorder, table)
-- bytestring
import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import Data.ByteString.Lazy (toStrict)
import qualified Data.ByteString.Lazy as LBS
-- case-insensitive
import Data.CaseInsensitive (original)
import qualified Data.CaseInsensitive as CaseInsensitive
-- containers
import qualified Data.Map as Map
-- lens
import Control.Lens
(ALens', _Just, cloneLens, set, view, (%=), (%~), (&), (.~), (^.), (^?))
-- microlens-mtl
import Lens.Micro.Mtl (zoom)
-- mime-types
import Network.Mime (defaultExtensionMap)
-- mtl
import Control.Monad.State.Class (get, gets, modify, put)
-- skylighting
import Skylighting
( Syntax
, TokenizerConfig(TokenizerConfig)
, defaultFormatOpts
, defaultSyntaxMap
, lookupSyntax
, pygments
, tokenize
)
-- text
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8Lenient, decodeUtf8With, encodeUtf8)
import Data.Text.Encoding.Error (lenientDecode)
import qualified Data.Text.Lazy as LText
-- text-zipper
import Data.Text.Zipper (getText, stringZipper)
-- unliftio
import UnliftIO.Exception (tryAnyDeep)
-- vector
import qualified Data.Vector as Vector
-- wreq
import qualified Network.Wreq as W
-- vty
import Graphics.Vty.Attributes
(Attr(attrStyle), MaybeDefault(..), Style, currentAttr, reverseVideo)
import qualified Graphics.Vty.Attributes as VA
import Graphics.Vty.Image
( Image
, charFill
, imageHeight
, imageWidth
, utf8Bytestring'
, vertCat
, (<->)
, (<|>)
)
import qualified Graphics.Vty.Image as VI
import Graphics.Vty.Input.Events
( Button(BScrollDown, BScrollUp)
, Event(EvKey, EvMouseDown)
, Key(KBackTab, KChar, KDown, KEnter, KEsc, KLeft, KPageDown, KPageUp, KRight, KUp)
, Modifier(MCtrl, MShift)
)
-- local libs
import HTTP.Client
( Header
, Method
, customMethodWith
, customPayloadMethodWith
, defaults
, headers
, knownMethods
, responseBody
, responseHeader
)
import Skylighting.Format.Vty (formatVty)
import Types
-- | Second argument must be positive.
splitExtraSpace :: RangeInAlign -> Int -> (Int, Int)
splitExtraSpace Min n = (0, n)
splitExtraSpace MidL n = let (q, r) = n `divMod` 2 in (q, q + r)
splitExtraSpace MidG n = let (q, r) = n `divMod` 2 in (q + r, q)
splitExtraSpace Max n = (n, 0)
{-|
Grows an image to a minimum size by padding it, with original images being aligned as specified
within the larger image.
-}
growWith
:: (Int -> Int -> Image) -- ^ How to generate wide by vert padding image
-> Int -- ^ minimum width
-> WideAlign -- ^ width-wise ("horizontal") alignment
-> Int -- ^ minimum vert ("height")
-> VertAlign -- ^ vertical alignment
-> Image -- ^ original, small image
-> Image -- ^ resulting, grown image
growWith fillImage w wa v va img = vertCat
[ fillImage width topPad
, fillImage leftPad vi <|> img <|> fillImage rightPad vi
, fillImage width bottomPad
]
where
vi = imageHeight img
vPad = max 0 (v - vi)
(topPad, bottomPad) = splitExtraSpace va vPad
wi = imageWidth img
width = max w wi
wPad = width - wi
(leftPad, rightPad) = splitExtraSpace wa wPad
{- Export?
growTransparent :: Int -> WideAlign -> Int -> VertAlign -> Image -> Image
growTransparent = growWith backgroundFill
-}
-- | See 'growWith', this one uses ASCII SPC @' '@ to fill the padding.
growSpaces :: Int -> WideAlign -> Int -> VertAlign -> Image -> Image
growSpaces = growWith (charFill currentAttr ' ')
-- | Update a widget by changing the final render and no other properties (almost a lens)
postprocessImage :: (Image -> Image) -> Widget n -> Widget n
postprocessImage process widget = widget{render = (imageL %~ process) <$> render widget}
-- | Lens, focus tracking from app state.
focusL :: Functor f => (AppR -> f AppR) -> AppS -> f AppS
focusL embed s = (\newFocus -> s{focus = newFocus}) <$> embed (focus s)
-- | Lens, method text entry from app state.
methodEditorL :: Functor f => (Editor Method AppR -> f (Editor Method AppR)) -> AppS -> f AppS
methodEditorL embed s = (\newMethodEditor -> s{methodEditor = newMethodEditor}) <$> embed (methodEditor s)
-- | Lens, method in text entry.
methodL :: Functor f => (Method -> f Method) -> AppS -> f AppS
methodL = methodEditorL . editContentsL . methodInEditorContentsL
where
methodInEditorContentsL embed tz = (\newMethod -> stringZipper [newMethod] (Just 1)) <$> embed (concat $ getText tz)
-- | Lens, optional overlay in app state
overlayStateL :: Functor f => (Maybe OverlayS -> f (Maybe OverlayS)) -> AppS -> f AppS
overlayStateL embed s = (\newOverlay -> s{overlayState = newOverlay}) <$> embed (overlayState s)
-- | Lens, location of pop-up in overlay state
methodEditorExtentL :: Functor f => (Extent AppR -> f (Extent AppR)) -> OverlayS -> f OverlayS
methodEditorExtentL embed os = (\newExtent -> os{methodEditorExtent = newExtent}) <$> embed (methodEditorExtent os)
-- | Lens, method selector in overlay state
methodListL :: Functor f => (List AppR Method -> f (List AppR Method)) -> OverlayS -> f OverlayS
methodListL embed os = (\newList -> os{methodList = newList}) <$> embed (methodList os)
-- | Lens, URL text entry in app state
urlEditorL :: Functor f => (Editor String AppR -> f (Editor String AppR)) -> AppS -> f AppS
urlEditorL embed s = (\newUrlEditor -> s{urlEditor = newUrlEditor}) <$> embed (urlEditor s)
-- | Lens, flag to use default headers
useDefaultHeadersL :: Functor f => (Bool -> f Bool) -> AppS -> f AppS
useDefaultHeadersL embed s =
(\newUse -> s{useDefaultHeaders = newUse}) <$> embed (useDefaultHeaders s)
-- | Lens, custom headers
customHeadersL :: Functor f => ([(Bool, Header)] -> f [(Bool, Header)]) -> AppS -> f AppS
customHeadersL embed s =
(\newHeaders -> s {customHeaders = newHeaders}) <$> embed (customHeaders s)
-- | Lens, header editor (maybe)
headerEditorL :: Functor f => (Maybe (Editor Text AppR) -> f (Maybe (Editor Text AppR))) -> AppS -> f AppS
headerEditorL embed s =
(\newHEdit -> s { headerEditor = newHEdit }) <$> embed (headerEditor s)
{-|
Like 'borderWithLabel' but grows the image of the inner widget so the whole label is always
visible.
-}
textLabeledBorder :: Text -> Widget n -> Widget n
textLabeledBorder label inner =
borderWithLabel (txt label) (postprocessImage (growSpaces (textWidth label) left 0 top) inner)
-- | Area between main menu and main content area
mainMenuSeparator :: Widget n
mainMenuSeparator = vLimit 1 $ fill '='
-- | Non-functional top menu bar
mainMenu :: Widget n
mainMenu = hBox $ map (padLeftRight 2 . txt) [ "Headers", "Response Tools", "\x2026" ]
data FocusSensitive = MkFocusSensitive
{ methodWidget :: Widget AppR
, urlWidget :: Widget AppR
, defaultHeadersToggleWidget :: Widget AppR
, customHeadersWidgets :: [[Widget AppR]]
, addCustomHeaderWidget :: Widget AppR
, responseBodyWidget :: Widget AppR
}
{-|
Render.
Widget names are all 'AppR'.
URL editor and label, then last response body at the top of the available space. No other widgets
or layers.
-}
draw :: AppS -> [Widget AppR]
draw AppS{..} =
[ drawOverlay os | Just os <- [overlayState] ]
++
[ vBox $
[ mainMenu
, mainMenuSeparator
, hBox
[ reportExtent MethodEditor $ textLabeledBorder "Method" methodWidget
, textLabeledBorder "URL to Query?" urlWidget
]
, defaultHeadersSection
]
++
[ textLabeledBorder "Custom Headers"
. renderTable
. surroundingBorder False
. rowBorders False
. table
$ customHeadersWidgets ++ [[addCustomHeaderWidget, txt "", txt ""]]
]
++
[ responseBodyWidget ]
] -- layers top to bottom
where
unfocusedWidgets = MkFocusSensitive
{ methodWidget = methodEditorWidget False
, urlWidget = editorWidget False urlEditor
, defaultHeadersToggleWidget = defaultHeadersToggle
, customHeadersWidgets = map unfocusedHeader customHeaders
, addCustomHeaderWidget = addCustomHeader
, responseBodyWidget = responseBodyView
}
methodEditorWidget isFocused = hLimit 18 $ hBox [editorWidget isFocused methodEditor, str "\x25BC"]
editorWidget = renderEditor (vBox . map str)
usingDefaultHeadersText = "[X] Use Default Headers"
(defaultHeadersToggle, defaultHeadersSection) =
if useDefaultHeaders
then
( txt usingDefaultHeadersText
, borderWithLabel
defaultHeadersToggleWidget
(postprocessImage (growSpaces (textWidth usingDefaultHeadersText) left 0 top) (headersWidget defaultHeaders))
)
else (txt "[ ] Use Default Headers", defaultHeadersToggleWidget)
where
defaultHeaders = view headers defaults
unfocusedHeader (active, (n, v)) = [unfocusedActive active, unfocusedName n, utf8 v ]
unfocusedActive active = txt . Text.singleton $ if active then 'X' else ' '
unfocusedName = utf8 . original
addCustomHeader = txt "+"
responseBodyView =
textLabeledBorder "Response Body"
. withVScrollBars OnRight
. withHScrollBars OnBottom
. viewport ResponseBodyView Both
$ raw lastResponse
MkFocusSensitive{..} = case focus of
MethodEditor -> unfocusedWidgets { methodWidget = methodEditorWidget True }
UrlEditor -> unfocusedWidgets { urlWidget = editorWidget True urlEditor }
DefaultHeadersToggle -> unfocusedWidgets { defaultHeadersToggleWidget = focusWidget defaultHeadersToggle }
ExistingCustomHeader chResource -> unfocusedWidgets { customHeadersWidgets = customHeadersWithFocus chResource }
AddCustomHeader -> unfocusedWidgets { addCustomHeaderWidget = focusWidget addCustomHeader }
ResponseBodyView -> unfocusedWidgets { responseBodyWidget = withBorderStyle unicodeBold responseBodyView }
MethodSelector -> unfocusedWidgets
customHeadersWithFocus MkCustomHeaderR{..} = zipWith headerRow [0..] customHeaders
where
headerRow i = if listIndex == i then focusedRow else unfocusedHeader
focusedRow (active, (n, v)) = case column of
ActiveToggle -> [focusWidget activeWidget, nameWidget, valueWidget]
NameEditor -> [activeWidget, maybe (focusWidget nameWidget) renderHeaderEditor headerEditor, valueWidget]
ValueEditor -> [activeWidget, nameWidget, maybe (focusWidget valueWidget) renderHeaderEditor headerEditor]
where
activeWidget = unfocusedActive active
nameWidget = unfocusedName n
valueWidget = utf8 v
renderHeaderEditor hedit =
hLimit (foldr (max . succ . textWidth) 1 $ getEditContents hedit)
. vLimit 1
$ renderEditor (vBox . map txt) True hedit
focusStyle :: Style -> Style
focusStyle x = x `xor` reverseVideo
focusAttr :: Attr -> Attr
focusAttr attr = attr { attrStyle = SetTo newStyle }
where
newStyle = case attrStyle attr of
Default -> reverseVideo
KeepCurrent -> reverseVideo
SetTo style -> focusStyle style
focusWidget :: Widget n -> Widget n
focusWidget = modifyDefAttr focusAttr
utf8 :: ByteString -> Widget n
utf8 bytes = Widget Fixed Fixed $ do
c <- getContext
return emptyResult { image = utf8Bytestring' (c ^. attrL) bytes }
headersWidget :: [Header] -> Widget n
headersWidget = renderTable . surroundingBorder False . rowBorders False . table . map headerRow
where
headerRow (n, v) = [utf8 $ original n, utf8 v]
-- | When the overlay is present, what are the widget to draw on that layer.
drawOverlay :: OverlayS -> Widget AppR
drawOverlay OverlayS{..} =
translateBy transLoc . hLimit w . textLabeledBorder "Method (Select)" $ methodSelectWidget
where
methodSelectWidget = vLimitPercent 50 . vLimit mv $ renderList (const str) True methodList
w = fst $ extentSize methodEditorExtent
transLoc = extentUpperLeft methodEditorExtent
mv = Vector.length (listElements methodList) * listItemHeight methodList
-- | Where to place the cursor? If overlay present, no cursor. Otherwise determined by 'focus'
chooseCursor :: AppS -> [CursorLocation AppR] -> Maybe (CursorLocation AppR)
chooseCursor s = showCursorNamed $ case overlayState s of
Nothing -> focus s
Just _ -> MethodSelector
-- | App state change on [Tab] to advace the focus.
moveFocusNext :: AppS -> AppS
moveFocusNext s = focusNext (customHeaders s) (focus s) s
where
focusNext cheaders = \case
MethodEditor -> focusL .~ UrlEditor
UrlEditor -> focusL .~ DefaultHeadersToggle
DefaultHeadersToggle -> focusL .~ nextFocus
where
nextFocus =
if null cheaders
then AddCustomHeader
else ExistingCustomHeader MkCustomHeaderR { listIndex = 0, column = ActiveToggle }
ExistingCustomHeader MkCustomHeaderR{..} -> \os -> case splitAt listIndex cheaders of
(initHdrs, (flag, (name, value)) : tailHdrs) -> case column of
ActiveToggle -> os
{ focus = nextFocus
, headerEditor = Just $ editorText nextFocus (Just 1) (decodeUtf8Lenient $ original name)
}
where
nextFocus = ExistingCustomHeader MkCustomHeaderR { listIndex, column = NameEditor }
NameEditor -> os
{ focus = nextFocus
, headerEditor = Just $ editorText nextFocus (Just 1) (decodeUtf8Lenient value)
, customHeaders =
initHdrs
++ (flag, (maybe name (CaseInsensitive.mk . getEditorUtf8) (headerEditor os), value))
: tailHdrs
}
where
nextFocus = ExistingCustomHeader MkCustomHeaderR { listIndex, column = ValueEditor }
ValueEditor ->
if null tailHdrs
then os
{ focus = AddCustomHeader
, headerEditor = Nothing
, customHeaders =
initHdrs ++ [(flag, (name, valueViaEditor))]
}
else os
{ focus = ExistingCustomHeader MkCustomHeaderR { listIndex = succ listIndex, column = ActiveToggle }
, headerEditor = Nothing
, customHeaders =
initHdrs ++ (flag, (name, valueViaEditor)) : tailHdrs
}
where
valueViaEditor = maybe value getEditorUtf8 (headerEditor os)
_ -> os { focus = AddCustomHeader, headerEditor = Nothing } -- NB: invalid focus
AddCustomHeader -> focusL .~ ResponseBodyView
ResponseBodyView -> focusL .~ MethodEditor
MethodSelector -> focusL .~ MethodEditor -- NB: invalid focus
-- | App state change on [S+Tab] to recede the focus
moveFocusPrev :: AppS -> AppS
moveFocusPrev s = case focus s of
MethodEditor -> s { focus = ResponseBodyView }
UrlEditor -> s { focus = MethodEditor }
DefaultHeadersToggle -> s { focus = UrlEditor }
ExistingCustomHeader MkCustomHeaderR { .. } -> case splitAt listIndex $ customHeaders s of
(initHdrs, (flag, (name, value)) : tailHdrs) -> case column of
ActiveToggle -> case unsnoc initHdrs of
Nothing -> s { focus = DefaultHeadersToggle }
Just (_, (_, (_, lastValue))) -> s
{ focus = newFocus
, headerEditor = Just . editorText newFocus (Just 1) $ decodeUtf8Lenient lastValue
}
where newFocus = ExistingCustomHeader MkCustomHeaderR { listIndex = pred listIndex, column = ValueEditor }
NameEditor -> s
{ focus = ExistingCustomHeader MkCustomHeaderR { listIndex, column = ActiveToggle }
, headerEditor = Nothing
, customHeaders =
initHdrs ++ (flag, (maybe name (CaseInsensitive.mk . getEditorUtf8) (headerEditor s), value)) : tailHdrs
}
ValueEditor -> s
{ focus = newFocus
, headerEditor = Just . editorText newFocus (Just 1) . decodeUtf8Lenient $ original name
, customHeaders =
initHdrs ++ (flag, (name, maybe value getEditorUtf8 $ headerEditor s)) : tailHdrs
}
where
newFocus = ExistingCustomHeader MkCustomHeaderR { listIndex, column = NameEditor }
_ -> s { focus = DefaultHeadersToggle, headerEditor = Nothing } -- NB: invalid focus
AddCustomHeader -> case unsnoc $ customHeaders s of
Nothing -> s { focus = DefaultHeadersToggle }
Just (initHdrs, (_, (_, value))) -> s
{ focus = newFocus
, headerEditor = Just $ editorText newFocus (Just 1) (decodeUtf8Lenient value)
}
where
newFocus = ExistingCustomHeader MkCustomHeaderR { listIndex = length initHdrs, column = ValueEditor }
ResponseBodyView -> s { focus = AddCustomHeader }
MethodSelector -> s { focus = ResponseBodyView } -- NB: invalid focus
{-|
State update in response to event.
Widget names are all 'Text'. No application specific events, so event type is 'Void'.
Global events handled here:
* [Esc] (with any or no modifiers) and Ctrl+q (with any or no other modifiers) halts.
* No application events are expected.
* Mouse events are ignored.
Other Vty events are passed to 'handleEventLocal'.
-}
handleEvent :: BrickEvent AppR Void -> EventM AppR AppS ()
handleEvent (VtyEvent (EvKey KEsc _)) = halt -- global: [ESC]: exit cleanly
handleEvent (VtyEvent e@(EvKey (KChar 'q') mods)) = -- global Ctrl+q: exit cleanly
if MCtrl `elem` mods
then halt
else handleEventLocal e
handleEvent (VtyEvent e) = handleEventLocal e
handleEvent (AppEvent bottom) = absurd bottom -- can't happen
handleEvent MouseDown{} = pure () -- ignore
handleEvent MouseUp{} = pure () -- ignore
{-|
If the overlay is deing displayed, defer to 'handleEventOverlay'. Otherwise, defer to
'handleEventMain'. "Global" events should already have been handled in 'handleEvent'.
-}
handleEventLocal :: Event -> EventM AppR AppS ()
handleEventLocal e = do
mos <- gets overlayState
-- switch to different behavior based on overlay
case mos of
Nothing -> handleEventMain e
Just os -> handleEventOverlay os e
{-|
Events for the main UI:
* [Enter] causes the application to 'doRequest'
* [Tab] causes 'moveFocusNext', [S+Tab] causes 'moveFocusPrev'.
* [BackTab] casues 'moveFocusPrev', [S+BackTab] causes 'moveFocusNext'.
* [Down] on the method text entry causes pop-up display; if focus is elsewhere it is ignored.
* [Space] on the default headers editor toggles the setting.
* If an editor is focused, other events update it.
-}
handleEventMain :: Event -> EventM AppR AppS ()
handleEventMain (EvKey KEnter _) = get >>= suspendAndResume . doRequest
handleEventMain (EvKey (KChar '\t') mods) = -- change focus
modify $ if MShift `elem` mods
then moveFocusPrev
else moveFocusNext
handleEventMain (EvKey KBackTab mods) = -- change focus
modify $ if MShift `elem` mods
then moveFocusNext
else moveFocusPrev
handleEventMain evt = do -- Depends on focus
s <- get
case focus s of
UrlEditor -> handleEditorLEvent urlEditorL evt
MethodEditor -> handleMethodEditorEvent evt
DefaultHeadersToggle -> handleDefaultHeadersEditorEvent evt
ExistingCustomHeader chResource -> handleCustomHeaderEvent chResource evt
AddCustomHeader -> handleAddCustomHeaderEvent evt
ResponseBodyView -> handleResponseBodyViewportEvent evt
MethodSelector -> pure () -- NB: invalid focus / should be in handleEventOverlay
-- | Pass event into 'handleEditorEvent', when not handled more specifically.
handleEditorLEvent :: ALens' AppS (Editor String AppR) -> Event -> EventM AppR AppS ()
handleEditorLEvent editorL evt = zoom (cloneLens editorL) (handleEditorEvent $ VtyEvent evt)
-- | Handle event when focus is on the method editor and will not change.
-- [Down] opens the method selector overlay.
-- Other events are passed to 'handleEditorLEvent'.
handleMethodEditorEvent :: Event -> EventM AppR AppS ()
handleMethodEditorEvent (EvKey KDown _mods) = do
s <- get
let
enteredMethod = view methodL s
listContents = Vector.fromList . nub $ enteredMethod : knownMethods
mMethodEditorExtent <- lookupExtent MethodEditor
for_ mMethodEditorExtent $ \methodEditorExtent ->
let
initialListState = set listSelectedL (Just 0) $ BrickList.list MethodSelector listContents 1
overlay = OverlayS
{ methodList = initialListState
, methodEditorExtent = methodEditorExtent
}
in put s{overlayState = Just overlay}
handleMethodEditorEvent evt = handleEditorLEvent methodEditorL evt
-- | Handle event when focus is on response body viewport and will not change.
-- * [Down] or 'j': line scroll down
-- * [Up] or 'k': line scroll up
-- * [Right] or 'l': column scroll right
-- * [Left] or 'h': column scroll left
-- * [PgDn]: page scroll down
-- * [PgUp]: page scroll up
-- * <ScrollUp>: 3 line scroll up
-- * <ScrollDown>: 3 line scroll down
handleResponseBodyViewportEvent :: Event -> EventM AppR AppS ()
handleResponseBodyViewportEvent evt =
case evt of
EvKey k _mods ->
case k of
KDown -> vScrollBy vps 1
KChar 'j' -> vScrollBy vps 1
KUp -> vScrollBy vps (-1)
KChar 'k' -> vScrollBy vps (-1)
KRight -> hScrollBy vps 1
KChar 'l' -> hScrollBy vps 1
KLeft -> hScrollBy vps (-1)
KChar 'h' -> hScrollBy vps (-1)
KPageDown -> vScrollPage vps Down
KPageUp -> vScrollPage vps Up
_ -> pure () -- ignored
EvMouseDown _x _y b _mods ->
case b of
BScrollUp -> vScrollBy vps (-3)
BScrollDown -> vScrollBy vps 3
_ -> pure () -- ignored
_ -> pure () -- ignored
where
vps = viewportScroll ResponseBodyView
-- | Handle event when focus in on default headers editor and will not change.
handleDefaultHeadersEditorEvent :: Event -> EventM AppR AppS ()
handleDefaultHeadersEditorEvent (EvKey (KChar ' ') _) = useDefaultHeadersL %= not
handleDefaultHeadersEditorEvent _ = pure ()
-- | If in the active column, SP toggles. Otherwise, pass to the active headerEditor (which _should_ not be Nothing).
--
-- FUTURE: Allow KUp and KDown to move the focus.
--
-- TBD: How to delete (not just deactivate) a custom header?
handleCustomHeaderEvent :: CustomHeaderR -> Event -> EventM AppR AppS ()
handleCustomHeaderEvent MkCustomHeaderR { listIndex, column = ActiveToggle } (EvKey (KChar ' ') _) =
customHeadersL %= toggleHeader
where
toggleHeader oldHeaders = case splitAt listIndex oldHeaders of
(initHdrs, (flag, hdr) : tailHdrs) -> initHdrs ++ (not flag, hdr) : tailHdrs
_ -> oldHeaders -- Invalid Index
handleCustomHeaderEvent _ evt = zoom (headerEditorL . _Just) . handleEditorEvent $ VtyEvent evt
-- | SP creates a new header.
handleAddCustomHeaderEvent :: Event -> EventM AppR AppS ()
handleAddCustomHeaderEvent (EvKey (KChar ' ') _) = modify addNewCustomHeader
where
addNewCustomHeader oldState = oldState
{ customHeaders = oldCustomHeaders ++ [(True, (CaseInsensitive.mk newName, ByteString.empty))]
, focus = newFocus
, headerEditor = Just . editorText newFocus (Just 1) $ decodeUtf8Lenient newName
}
where
oldCustomHeaders = customHeaders oldState
newName = ByteString.empty
newFocus = ExistingCustomHeader MkCustomHeaderR { listIndex = length oldCustomHeaders, column = NameEditor }
handleAddCustomHeaderEvent _ = pure ()
-- | Retrieve extent emitted during last draw and update overlay state.
updateExtent :: EventM AppR OverlayS ()
updateExtent = do
mExtent <- lookupExtent MethodEditor
for_ mExtent $ \extent ->
modify $ set methodEditorExtentL extent
{-|
Events for the overlay:
* [Enter] closes the overlay taking the selected method and updating the application state.
* Other events are sent to the method selection list.
All code paths should 'updateExtent', in case the pop-up needs to move.
-}
handleEventOverlay :: OverlayS -> Event -> EventM AppR AppS ()
handleEventOverlay currentOverlay (EvKey KEnter _) = do -- pick selected method, remove popup
mSelectedMethod <- fmap snd . nestEventM currentOverlay $ do
updateExtent
gets $ fmap snd . listSelectedElement . methodList
for_ mSelectedMethod $ \method ->
modify $ set methodL method . set overlayStateL Nothing
handleEventOverlay _currentOverlay e = zoom (overlayStateL . _Just) $ do -- defer to list
updateExtent
zoom methodListL (handleListEvent e)
getEditorUtf8 :: Editor Text n -> ByteString
getEditorUtf8 = encodeUtf8 . mconcat . getEditContents
{-|
Construct and issue a wreq request from the application state, handle request failures that are
raised as exceptions in IO, updating the application state ('lastResponse') either from the
response or the execption.
-}
doRequest :: AppS -> IO AppS
doRequest s = do
result <- tryAnyDeep $ do
let opts = W.defaults & (W.manager .~ Right (connManager s))
response <- httpRequest opts . concat . getText . view editContentsL $ urlEditor s
pure $ syntaxHighlightResponse (pure $ view responseBody response) (responseContentType response)
pure s{ lastResponse = either exResponse id result }
where
justSndIfFst (active, value) = if active then Just value else Nothing
updateHeaders AppS{..} = if useDefaultHeaders then (++ activeCustomHeaders) else const activeCustomHeaders
where
activeCustomHeaders = case focus of
ExistingCustomHeader MkCustomHeaderR{..} -> catMaybes $ zipWith focusedOrSaved [0..] customHeaders
where
focusedOrSaved _i (False, _) = Nothing
focusedOrSaved i (True, hdr) = if listIndex == i then Just $ focusByColumn hdr else Just hdr
focusByColumn hdr = case column of
ActiveToggle -> hdr
NameEditor -> (maybe (fst hdr) (CaseInsensitive.mk . getEditorUtf8) headerEditor, snd hdr)
ValueEditor -> (fst hdr, maybe (snd hdr) getEditorUtf8 headerEditor)
_ -> mapMaybe justSndIfFst customHeaders
httpMethod AppS{..} = concat . getText $ view editContentsL methodEditor
httpOptions = headers %~ updateHeaders s
httpRequest opts =
case payload s of
Nothing -> customMethodWith (httpMethod s) (httpOptions opts)
Just lbs -> \url -> customPayloadMethodWith (httpMethod s) (httpOptions opts) url lbs
exResponse ex = VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default) $ "[Exception: " <> LText.pack (show ex) <> "]"
responseContentType resp = fmap LBS.fromStrict $ resp ^? responseHeader "Content-Type"
{- Incoming ByteString for payload has not been pruned of invalid chacaters nor been tab expanded.
- These operations are required to be compatible with Vty's text rendering. The require removing
paticular characters like escape, tab, vertical tab, and new lines. The newslines are dealt with as part of the
-}
syntaxHighlightResponse :: Maybe LBS.ByteString -> Maybe LBS.ByteString -> Image
syntaxHighlightResponse Nothing _ = VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default) "-- No response body --\n"
syntaxHighlightResponse (Just payload) Nothing = noSyntaxFound $ cleanPayload payload
syntaxHighlightResponse (Just payload) (Just contentType) = case syntaxFromExtension of
(syn:_) -> highlighted syn $ cleanPayload payload
[] -> noSyntaxFound $ cleanPayload payload
where
syntaxFromExtension = mapMaybe (`lookupSyntax` defaultSyntaxMap) contentTypeToExtensionList
contentTypeToExtensionList = fromMaybe [] $ Map.lookup mimeType defaultExtensionMap
mimeType = encodeUtf8 $ Text.takeWhile (/= ';') $ lbsToText contentType
cleanPayload :: LBS.ByteString -> Text
cleanPayload = expandTabs . cleanText
where
cleanText = Text.filter (`notElem` ['\ESC', '\v']) . lbsToText
expandTabs = Text.replace "\t" " "
highlighted :: Syntax -> Text -> Image
highlighted s t = case tokenize (TokenizerConfig defaultSyntaxMap False) s t of
Left err -> foldr ((<->) . VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default))
VI.emptyImage
["-- Syntax highlighting error: ", LText.pack err, " --" , LText.fromStrict t]
Right sourceLines -> formatVty defaultFormatOpts pygments sourceLines
noSyntaxFound :: Text -> Image
noSyntaxFound payload = foldr
((<->) . VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default))
VI.emptyImage
("--Uknown Body Type --" : map LText.fromStrict (Text.lines payload))
-- Helper function to convert a lazy ByteString to Text in UTF-8 encoding
lbsToText :: LBS.ByteString -> Text
lbsToText = decodeUtf8With lenientDecode . toStrict