{-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -- | The GTK4 / WebKit front end. The book's structure loads in the background; -- chapters are rendered lazily (and cached) the first time they're visited, -- with neighbours preloaded so arrow-key paging feels instant. module UI (runApp) where import Data.Bifunctor (first) import Control.Concurrent.Async (async) import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar, readTVar, modifyTVar') import Data.List (minimumBy) import Data.Ord (comparing) import Data.Maybe (listToMaybe) import Control.Monad (unless, void, when) import Data.GI.Base (AttrOp (..), on) import qualified Data.Map.Strict as M import qualified Data.Text as T import Data.Word (Word32) import Data.Bits ((.|.)) import Control.Exception (SomeException, try) import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import EpubParser (EpubEnv, openEpub) -- brings the `BookInfo EpubEnv` instance into scope import qualified GI.GLib as GLib import qualified GI.GLib.Constants as GLibConst import qualified GI.Gdk as Gdk import qualified GI.Gio as Gio import qualified GI.Gtk as Gtk import qualified GI.Gtk.Enums as GtkEnums import qualified GI.WebKit as WebKit import qualified GI.JavaScriptCore as JSC import Persistence (loadLastRead, saveLastRead) import State (initialState) import Types ( AppState (..), BookInfo (..), BookStructure (..), Chapter (..), ChapterIndex (..), ChapterRef (..), TocEntry (..), UserAction (..), ReadMode(..), Html(..), PlainText(..), ) -- | Widget handles the rest of the module needs. data AppWidgets = AppWidgets { appWindow :: Gtk.ApplicationWindow, appWebView :: WebKit.WebView, appSidebar :: Gtk.ListBox, appFileBttn :: Gtk.Button, appSearch :: Gtk.SearchEntry } -- | Everything an action handler needs: shared state, widgets, and a way to -- render a chapter on demand (closes over the book). data Ctx = Ctx { ctxState :: TVar AppState, ctxWidgets :: AppWidgets, ctxRender :: TVar (ChapterRef -> IO (Either String Chapter)), ctxPending :: TVar (Maybe T.Text) -- Query to run once the next load finished (text search) } -- | The cache will start evicting after exceeding this number using LRU. cacheCap :: Int cacheCap = 7 -------------------------------------------------------------------------------- -- Widget tree -------------------------------------------------------------------------------- buildLayout :: Gtk.Application -> IO AppWidgets buildLayout app = do window <- Gtk.applicationWindowNew app webView <- WebKit.webViewNew sidebar <- Gtk.listBoxNew header <- Gtk.headerBarNew fileBttn <- Gtk.buttonNew sEntry <- Gtk.searchEntryNew Gtk.listBoxSetSelectionMode sidebar GtkEnums.SelectionModeSingle Gtk.widgetAddCssClass sidebar "toc" scrolled <- Gtk.scrolledWindowNew Gtk.scrolledWindowSetChild scrolled (Just sidebar) Gtk.widgetSetSizeRequest scrolled 260 (-1) paned <- Gtk.panedNew GtkEnums.OrientationHorizontal Gtk.panedSetStartChild paned (Just scrolled) Gtk.panedSetEndChild paned (Just webView) Gtk.panedSetResizeStartChild paned False Gtk.headerBarPackStart header fileBttn Gtk.headerBarPackEnd header sEntry Gtk.set window [#defaultWidth := 1000, #defaultHeight := 700, #child := paned] Gtk.windowSetTitlebar window $ Just header applyStyles window pure (AppWidgets window webView sidebar fileBttn sEntry) -- | Install the app-wide stylesheet (sidebar rows, selection, headings). applyStyles :: Gtk.ApplicationWindow -> IO () applyStyles window = do provider <- Gtk.cssProviderNew Gtk.cssProviderLoadFromString provider css disp <- Gtk.widgetGetDisplay window Gtk.styleContextAddProviderForDisplay disp provider appPriority where appPriority = 600 :: Word32 css = T.concat [ ".toc { background: transparent; padding: 4px; }", ".toc > row { padding: 6px 10px; border-radius: 6px; margin: 1px 4px; }", ".toc > row:selected { background-color: rgba(90,130,220,0.35); }", ".toc > row:hover { background-color: rgba(128,128,128,0.14); }", "label.toc-top { font-weight: bold; }" ] -------------------------------------------------------------------------------- -- Sidebar (table of contents) -------------------------------------------------------------------------------- -- | A displayable sidebar row: nesting depth, label, the chapter it jumps to, -- and an optional in-page anchor. data Row = Row Int T.Text ChapterIndex T.Text -- | Flatten the TOC tree into rows, remembering depth (for indentation) and -- the chapter each entry targets. With no TOC, list chapters flatly. displayRows :: [ChapterRef] -> [TocEntry] -> [Row] displayRows refs toc | null toc = [Row 0 (refTitle r) (refIndex r) "" | r <- refs] | otherwise = go 0 toc where pathToIndex = M.fromList [(refPath r, refIndex r) | r <- refs] indexOf target = M.findWithDefault (ChapterIndex 0) target pathToIndex go depth = concatMap (\t -> Row depth (tocLabel t) (indexOf (tocTarget t)) (tocFragment t) : go (depth + 1) (tocChildren t)) populateSidebar :: Gtk.ListBox -> [Row] -> IO () populateSidebar sidebar = mapM_ $ \(Row depth label _ _) -> do lbl <- Gtk.labelNew (Just label) Gtk.widgetSetHalign lbl GtkEnums.AlignStart Gtk.labelSetXalign lbl 0 Gtk.labelSetWrap lbl True Gtk.widgetSetMarginStart lbl (fromIntegral (depth * 14)) when (depth == 0) (Gtk.widgetAddCssClass lbl "toc-top") Gtk.listBoxInsert sidebar lbl (-1) -------------------------------------------------------------------------------- -- Events -------------------------------------------------------------------------------- -- | Delegates focus to an internal @GtkText@, so the window's focus widget -- is a descendant of the entry. searchFocused :: Ctx -> IO Bool searchFocused ctx = do mfocus <- Gtk.windowGetFocus (appWindow (ctxWidgets ctx)) case mfocus of Nothing -> pure False Just f -> Gtk.widgetIsAncestor f (appSearch (ctxWidgets ctx)) wireEvents :: Ctx -> IO () wireEvents ctx = do let dispatch = handleAction ctx keyCtrl <- Gtk.eventControllerKeyNew Gtk.eventControllerSetPropagationPhase keyCtrl GtkEnums.PropagationPhaseCapture _ <- on keyCtrl #keyPressed $ \keyval _ _ -> do -- The controller is in capture phase, so it sees keys before the focused -- search box. While the user is typing a query, let every key through -- untouched (otherwise letters like `p`/`f` fire commands and the arrows -- page the view — the latter crashes in scrolling mode via `turnPage`). searching <- searchFocused ctx if searching then pure False else case keyval of Gdk.KEY_Right -> dispatch NextPage >> pure True Gdk.KEY_Left -> dispatch PrevPage >> pure True Gdk.KEY_Page_Down -> dispatch NextChapter >> pure True Gdk.KEY_Page_Up -> dispatch PrevChapter >> pure True Gdk.KEY_equal -> dispatch ZoomIn >> pure True Gdk.KEY_minus -> dispatch ZoomOut >> pure True Gdk.KEY_p -> dispatch ToggleMode >> pure True Gdk.KEY_f -> dispatch SearchFocus >> pure True _ -> pure False Gtk.widgetAddController (appWindow (ctxWidgets ctx)) keyCtrl _ <- on (appFileBttn (ctxWidgets ctx)) #clicked $ epPicker (Just (appWindow (ctxWidgets ctx))) ctx fc <- WebKit.webViewGetFindController (appWebView (ctxWidgets ctx)) let entry = (appSearch (ctxWidgets ctx)) let findOpts = fromIntegral $ fromEnum WebKit.FindOptionsCaseInsensitive .|. fromEnum WebKit.FindOptionsWrapAround searchKeys <- Gtk.eventControllerKeyNew _ <- on searchKeys #keyPressed $ \keyval _ _ -> case keyval of Gdk.KEY_Down -> WebKit.findControllerSearchNext fc >> pure True Gdk.KEY_Up -> WebKit.findControllerSearchPrevious fc >> pure True Gdk.KEY_Escape -> Gtk.setEditableText entry "" >> WebKit.findControllerSearchFinish fc >> Gtk.widgetGrabFocus (appWebView (ctxWidgets ctx)) >> snapPagination ctx >> pure True _ -> pure False Gtk.widgetAddController entry searchKeys _ <- on entry #searchChanged $ do q <- Gtk.editableGetText entry if T.null q then WebKit.findControllerSearchFinish fc >> snapPagination ctx else WebKit.findControllerSearch fc q findOpts maxBound let runfwd = runSearch ctx nextMatch _ <- on entry #activate $ runfwd -- pressing enter while having search text _ <- on entry #nextMatch $ runfwd -- pressing Alt + G while having search text _ <- on entry #previousMatch $ runSearch ctx prevMatch _ <- on (appWebView (ctxWidgets ctx)) #loadChanged $ \ev -> when (ev == WebKit.LoadEventFinished) $ do mq <- atomically $ readTVar (ctxPending ctx) case mq of Just q -> WebKit.findControllerSearch fc q findOpts maxBound; _ -> pure () _ <- on fc #foundText $ \_ -> atomically $ writeTVar (ctxPending ctx) Nothing _ <- on (appSidebar (ctxWidgets ctx)) #rowActivated $ \row -> do i <- Gtk.listBoxRowGetIndex row st <- readTVarIO (ctxState ctx) case drop (fromIntegral i) (displayRows (stRefs st) (stToc st)) of (Row _ _ idx frag : _) -> dispatch (GoToChapter idx frag) [] -> pure () pure () -------------------------------------------------------------------------------- -- Loading -------------------------------------------------------------------------------- -- | Load the book's structure off the GTK thread, then populate the sidebar -- and open the first (or last-read) chapter back on the main loop. loadBook :: (BookInfo a) => a -> Ctx -> IO () loadBook env ctx = do myGen <- atomically $ do s <- readTVar (ctxState ctx) let g = stGen s + 1 writeTVar (ctxState ctx) (s {stGen = g}) pure g void $ async $ do result <- loadStructure env case result of Left err -> putStrLn ("Failed to load book: " ++ err) Right (BookStructure refs toc) -> do committed <- atomically $ do s <- readTVar (ctxState ctx) let mine = stGen s == myGen when mine $ do writeTVar (ctxState ctx) (s { stRefs = refs, stToc = toc, stCache = M.empty, stIndex = ChapterIndex 0, stTitle = bookTitle env, stBookPath = bookFilePath env, stSearchIndex = M.empty}) writeTVar (ctxRender ctx) (renderChapter env) pure mine when committed $ do postGtk $ do s <- readTVarIO (ctxState ctx) when (stGen s == myGen) $ do Gtk.listBoxRemoveAll (appSidebar (ctxWidgets ctx)) populateSidebar (appSidebar (ctxWidgets ctx)) (displayRows refs toc) saved <- loadLastRead let start = case saved of Just (path, idx) | path == bookFilePath env -> idx _ -> ChapterIndex 0 handleAction ctx (GoToChapter start "") void $ async $ do -- Initiate plaintext chapter parsing, used in full-book text search pairs <- mapM (\ref -> do r <- chapterText env ref pure $ case r of Left err -> Left err Right txt -> Right (refIndex ref, txt)) refs let searchIndex = M.fromList [ (idx, txt) | Right (idx, txt) <- pairs ] atomically $ do s <- readTVar (ctxState ctx) when (stGen s == myGen) $ writeTVar (ctxState ctx) (s { stSearchIndex = searchIndex }) epPicker :: Maybe Gtk.ApplicationWindow -> Ctx -> IO () epPicker window ctx = do dg <- Gtk.fileDialogNew Gtk.fileDialogOpen dg window (Nothing :: Maybe Gio.Cancellable) $ Just $ \_ result -> do outcome <- runExceptT $ do file <- pickerGetFile dg result path <- getFilePath file env <- extractEnv path liftIO $ loadBook env ctx either putStrLn pure outcome pickerGetFile :: Gtk.FileDialog -> Gio.AsyncResult -> ExceptT String IO Gio.File pickerGetFile dg result = ExceptT $ first (const "picker cancelled or failed") <$> (try (Gtk.fileDialogOpenFinish dg result) :: IO (Either SomeException Gio.File)) getFilePath :: Gio.File -> ExceptT String IO FilePath getFilePath file = do mp <- liftIO $ Gio.fileGetPath file maybe (throwError "selected file has no local path") pure mp extractEnv :: FilePath -> ExceptT String IO EpubEnv extractEnv path = ExceptT $ first ("open failed: " ++) <$> openEpub path -------------------------------------------------------------------------------- -- Navigation + rendering -------------------------------------------------------------------------------- handleAction :: Ctx -> UserAction -> IO () handleAction ctx action = do st <- readTVarIO (ctxState ctx) let wv = appWebView (ctxWidgets ctx) let entry = appSearch (ctxWidgets ctx) case action of ZoomIn -> setZoom ctx (stZoom st + 0.1) ZoomOut -> setZoom ctx (max 0.1 (stZoom st - 0.1)) NextChapter -> goto ctx (stIndex st + 1) StartTop PrevChapter -> goto ctx (stIndex st - 1) StartTop GoToChapter i frag -> goto ctx i (startOfFrag frag) -- `turnPage` only exists in Paginated mode (it's injected by `pageScript`). -- In Scrolling mode there's nothing to page within, so move by chapter. NextPage -> case stMode st of Paginated -> evalJSBool wv "turnPage(1)" $ \moved -> unless moved $ goto ctx (stIndex st + 1) StartTop Scrolling -> goto ctx (stIndex st + 1) StartTop PrevPage -> case stMode st of Paginated -> evalJSBool wv "turnPage(-1)" $ \moved -> unless moved $ goto ctx (stIndex st - 1) StartLast Scrolling -> goto ctx (stIndex st - 1) StartTop ToggleMode -> do let newMode = if stMode st == Paginated then Scrolling else Paginated atomically $ modifyTVar' (ctxState ctx) $ \s -> s { stMode = newMode } goto ctx (stIndex st) StartTop SearchFocus -> void $ Gtk.widgetGrabFocus entry -- | Where to land when a chapter loads: the first page, a specific TOC -- @#fragment@'s page, or the last page (used when paging backwards into the -- previous chapter). data StartAt = StartTop | StartFrag T.Text | StartLast -- | Map a raw TOC fragment (possibly empty) to a landing position. startOfFrag :: T.Text -> StartAt startOfFrag f = if T.null f then StartTop else StartFrag f -- | Move to a chapter (clamped), render it if needed, land at @start@, and -- remember the position. Neighbours are preloaded afterwards. goto :: Ctx -> ChapterIndex -> StartAt -> IO () goto ctx target start = do st <- readTVarIO (ctxState ctx) let refs = stRefs st total = length refs when (total > 0) $ do let ChapterIndex t = target i = max 0 (min (total - 1) t) idx = ChapterIndex i ref = refs !! i atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stIndex = idx} void $ async (saveLastRead (stBookPath st) idx) case M.lookup idx (stCache st) of Just chapter -> display ctx chapter start Nothing -> do showLoading ctx ref void $ async $ do renderFn <- readTVarIO (ctxRender ctx) rendered <- renderFn ref case rendered of Left err -> putStrLn ("render failed: " ++ err) Right chapter -> do atomically $ modifyTVar' (ctxState ctx) $ \s -> insertLRU idx chapter s postGtk $ do cur <- stIndex <$> readTVarIO (ctxState ctx) when (cur == idx) (display ctx chapter start) preload ctx -- | Bump the tick of an index to track frequency for cache eviction. touch :: ChapterIndex -> AppState -> AppState touch idx s = let t = stTick s + 1 in s { stTick = t, stUse = M.insert idx t (stUse s) } -- | Insert a rendered chapter as MRU or evict the LRU entry. insertLRU :: ChapterIndex -> Chapter -> AppState -> AppState insertLRU idx chapter s0 = let s = touch idx (s0 {stCache = M.insert idx chapter (stCache s0) }) in if M.size (stCache s) <= cacheCap then s else evictLRU s evictLRU :: AppState -> AppState evictLRU s = case M.toList (stUse s) of [] -> s xs -> let victim = fst (minimumBy (comparing snd) xs) in s {stCache = M.delete victim (stCache s), stUse = M.delete victim (stUse s)} -- | Render the chapters on either side of the current one into the cache. preload :: Ctx -> IO () preload ctx = do st <- readTVarIO (ctxState ctx) let refs = stRefs st total = length refs ChapterIndex i = stIndex st mapM_ (cacheRef ctx refs) (filter (\n -> n >= 0 && n < total) [i - 1, i + 1]) cacheRef :: Ctx -> [ChapterRef] -> Int -> IO () cacheRef ctx refs n = do let idx = ChapterIndex n st <- readTVarIO (ctxState ctx) unless (M.member idx (stCache st)) $ void $ async $ do renderFn <- readTVarIO (ctxRender ctx) rendered <- renderFn (refs !! n) case rendered of Right chapter -> atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stCache = M.insert idx chapter (stCache s)} Left _ -> pure () getMode :: Ctx -> IO ReadMode getMode ctx = stMode <$> readTVarIO (ctxState ctx) display :: Ctx -> Chapter -> StartAt -> IO () display ctx chapter start = do mode <- getMode ctx setWindowTitle ctx (chapterIndex chapter) (chapterTitle chapter) WebKit.webViewLoadHtml (appWebView (ctxWidgets ctx)) (wrapHtml (chapterHtml chapter) start mode) Nothing showLoading :: Ctx -> ChapterRef -> IO () showLoading ctx ref = do mode <- getMode ctx setWindowTitle ctx (refIndex ref) (refTitle ref) WebKit.webViewLoadHtml (appWebView (ctxWidgets ctx)) (wrapHtml (Html "

Loading…

") StartTop mode) Nothing setWindowTitle :: Ctx -> ChapterIndex -> T.Text -> IO () setWindowTitle ctx (ChapterIndex i) chapter = do st <- readTVarIO (ctxState ctx) let total = length (stRefs st) title = T.pack (show (i + 1)) <> "/" <> T.pack (show total) <> " " <> stTitle st <> " — " <> chapter Gtk.set (appWindow (ctxWidgets ctx)) [#title := title] setZoom :: Ctx -> Double -> IO () setZoom ctx level = do atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stZoom = level} WebKit.webViewSetZoomLevel (appWebView (ctxWidgets ctx)) level -- | Wrap a chapter's @\@ fragment in a full, styled HTML document -- (WebKit renders bare fragments unreliably). If @frag@ names an anchor, a -- small script scrolls it into view after load. wrapHtml :: Html -> StartAt -> ReadMode -> T.Text wrapHtml (Html body) start mode = T.concat [ "", body, -- The page controller only exists in paginated mode; scroll mode is a -- plain, natively-scrolling document with no injected script. ( case mode of Paginated -> pageScript <> startScript start Scrolling -> "" ), "" ] where -- Paginated layout: the body is a CSS multi-column box. Each column is -- half the viewport, so two columns (a two-page spread) show at once, like -- an open book. html clips the overflow; we reveal the next spread by -- translating the body left by N*100vw (one viewport = one spread). Each -- top-level block fills its 50vw column; horizontal padding gives the page -- its side margins, and the inner paddings meet to form the centre gutter. paginatedCss = T.concat [ "*{box-sizing:border-box;}", "html,body{height:100%;margin:0;padding:0;}", "html{overflow:hidden;}", "body{height:100vh;column-width:50vw;column-gap:0;column-fill:auto;", "font-family:Georgia,serif;line-height:1.6;}", "body>*{padding-left:3rem;padding-right:3rem;}", "img{max-width:100%;height:auto;}" ] -- Scroll layout: an ordinary centred, vertically-scrolling web page. No -- columns, no transform, no page controller. scrollCss = T.concat [ "html,body{height:100%;margin:0;padding:0;}", "body{max-width:42rem;margin:0 auto;padding:2rem 1rem;overflow-y:auto;", "font-family:Georgia,serif;line-height:1.6;}", "img{max-width:100%;height:auto;}" ] -- The JS "page controller": a dumb rendering primitive. `off` is the -- current page index. turnPage returns false when it would run off either -- end, which is the signal Haskell uses to flip to the prev/next chapter. pageScript = "" -- Where to land once the page controller is defined. StartLast waits for -- 'load' so images have sized and pages() reflects the true total width. startScript StartTop = "" startScript StartLast = "" -- On a TOC #fragment jump, translate the anchor's x-offset into a page -- index (scrollIntoView is a no-op once overflow is hidden). startScript (StartFrag f) = let safe = T.filter (\c -> c /= '"' && c /= '\\') f in "" -- | Re-align the paginated view after an in-page find. WebKit reveals a match -- by scrolling the (multi-column) layout, which leaves the transform-based -- pager out of sync so several half-pages show at once once the search ends. -- Reset the scroll offset back to zero and re-apply the current page transform. -- No-op in scrolling mode, which scrolls natively and needs no fix-up. snapPagination :: Ctx -> IO () snapPagination ctx = do mode <- getMode ctx when (mode == Paginated) $ evalJSBool (appWebView (ctxWidgets ctx)) ( T.concat [ "var d=document.documentElement,b=document.body;", "d.scrollLeft=0;d.scrollTop=0;if(b){b.scrollLeft=0;b.scrollTop=0;}", "window.scrollTo(0,0);if(typeof apply==='function'){apply();}true" ] ) (\_ -> pure ()) evalJSBool :: WebKit.WebView -> T.Text -> (Bool -> IO ()) -> IO () evalJSBool wv src k = WebKit.webViewEvaluateJavascript wv src (-1) Nothing Nothing (Nothing :: Maybe Gio.Cancellable) (Just $ \_ res -> do -- A JS runtime error makes `...Finish` throw; treat that as `False` -- rather than letting the exception abort the whole program. r <- try (WebKit.webViewEvaluateJavascriptFinish wv res >>= JSC.valueToBoolean) case r of Right b -> k b Left (_ :: SomeException) -> k False) chaptersMatching :: T.Text -> M.Map ChapterIndex PlainText -> [ChapterIndex] chaptersMatching q = map fst . filter (matches q . snd) . M.toAscList matches :: T.Text -> PlainText -> Bool matches q (PlainText txt) = T.toCaseFold q `T.isInfixOf` T.toCaseFold txt nextMatch :: ChapterIndex -> [ChapterIndex] -> Maybe ChapterIndex nextMatch cur hits = case filter (> cur) hits of (x : _) -> Just x [] -> listToMaybe hits prevMatch :: ChapterIndex -> [ChapterIndex] -> Maybe ChapterIndex prevMatch cur hits = case reverse (filter (< cur) hits) of (x : _) -> Just x [] -> listToMaybe hits runSearch :: Ctx -> (ChapterIndex -> [ChapterIndex] -> Maybe ChapterIndex) -> IO () runSearch ctx pick = do q <- Gtk.editableGetText (appSearch (ctxWidgets ctx)) unless (T.null q) $ do st <- readTVarIO (ctxState ctx) let hits = chaptersMatching q (stSearchIndex st) case pick (stIndex st) hits of Just idx | idx /= stIndex st -> do _ <- atomically $ writeTVar (ctxPending ctx) (Just q) handleAction ctx (GoToChapter idx "") _ -> pure () -------------------------------------------------------------------------------- -- Entry point -------------------------------------------------------------------------------- -- | Schedule an action to run on the GTK main loop. postGtk :: IO () -> IO () postGtk act = void $ GLib.idleAdd GLibConst.PRIORITY_DEFAULT (act >> pure False) activate :: EpubEnv -> Gtk.Application -> IO () activate env app = do stateTVar <- newTVarIO (initialState (bookTitle env) (bookFilePath env)) renderVar <- newTVarIO (renderChapter env) widgets <- buildLayout app pendingVar <- newTVarIO Nothing let ctx = Ctx stateTVar widgets renderVar pendingVar wireEvents ctx loadBook env ctx #present (appWindow widgets) runApp :: EpubEnv -> IO () runApp env = do app <- Gtk.new Gtk.Application [#applicationId := "com.svitak.reader", #flags := [Gio.ApplicationFlagsDefaultFlags]] _ <- on app #activate (activate env app) _ <- Gio.applicationRun app Nothing pure ()