forked from marko/svitak
614 lines
25 KiB
Haskell
614 lines
25 KiB
Haskell
{-# 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 "<p style=\"opacity:0.5\">Loading…</p>") 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 @\<body\>@ 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
|
|
[ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>",
|
|
( case mode of
|
|
Paginated -> paginatedCss
|
|
Scrolling -> scrollCss
|
|
),
|
|
"</style></head><body>",
|
|
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 -> ""
|
|
),
|
|
"</body></html>"
|
|
]
|
|
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 =
|
|
"<script>var off=0;"
|
|
<> "function pages(){return Math.max(1,Math.ceil(document.body.scrollWidth/window.innerWidth));}"
|
|
<> "function apply(){document.body.style.transform='translateX('+(-off*100)+'vw)';}"
|
|
<> "function turnPage(d){var n=off+d;if(n<0||n>=pages())return false;off=n;apply();return true;}"
|
|
<> "function goToPage(p){off=Math.max(0,Math.min(p,pages()-1));apply();}"
|
|
<> "window.addEventListener('resize',function(){goToPage(off);});</script>"
|
|
-- 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 =
|
|
"<script>window.addEventListener('load',function(){goToPage(pages()-1);});</script>"
|
|
-- 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 "<script>var el=document.getElementById(\"" <> safe <> "\");if(el)goToPage(Math.floor(el.offsetLeft/window.innerWidth));</script>"
|
|
|
|
|
|
-- | 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 ()
|