Compare commits

..

5 Commits

Author SHA1 Message Date
sxlxc 3f0d36015b update readme 2026-03-31 23:27:55 +08:00
Codex 3a3fd97055 Split TUI from default watch mode 2026-03-30 11:05:44 +08:00
sxlxc aa05d73c9c Clean up CSS and update default styles
Normalize formatting across CSS files and fix selector whitespace
and quote usage. Change default link color to browser blue and
enable smooth scrolling; hover underlines links. Revamp table
styles (borders, caption, zebra rows, padding, footer alignment).
Adjust proof/theorem header spacing and minor spacing/padding fixes.
2026-03-30 10:42:25 +08:00
sxlxc 3652459503 Add Unix and enable stdin-driven quit in watch TUI 2026-03-29 13:48:43 +08:00
sxlxc fc4cac00d5 format code 2026-03-29 13:38:58 +08:00
6 changed files with 458 additions and 296 deletions
+21 -8
View File
@@ -11,22 +11,22 @@
} }
.theorem-header .index:before { .theorem-header .index:before {
content: ' '; content: " ";
} }
.theorem-header .name:before { .theorem-header .name:before {
content: ' ('; content: " (";
} }
.theorem-header .name:after { .theorem-header .name:after {
content: ')'; content: ")";
} }
.theorem-header:after { .theorem-header:after {
content: '.\2002\2002'; content: ".\2002\2002";
} }
.theorem-header+p { .theorem-header + p {
display: inline; display: inline;
} }
@@ -42,12 +42,25 @@
} }
.Proof:after { .Proof:after {
content: '∎'; content: "∎";
position: absolute; position: absolute;
right: 0px; right: 0px;
bottom: 0px; bottom: 0px;
} }
.Proof span.theorem-header span.name {
font-weight: normal;
font-style: italic;
}
.Proof span.theorem-header span.name:before {
content: " ";
}
.Proof span.theorem-header span.name:after {
content: " ";
}
table.postindex { table.postindex {
width: 100%; width: 100%;
} }
@@ -66,7 +79,7 @@ table.postindex td.right {
} }
.header-section-number:after { .header-section-number:after {
content: '.'; content: ".";
} }
.csl-entry { .csl-entry {
@@ -85,6 +98,6 @@ table.postindex td.right {
.csl-right-inline { .csl-right-inline {
display: table-cell; display: table-cell;
} }
.csl-right-inline a{ .csl-right-inline a {
word-break: break-all; word-break: break-all;
} }
+66 -119
View File
@@ -3,10 +3,7 @@
--color-tag1: gray; --color-tag1: gray;
--color-tag2: darkolivegreen; --color-tag2: darkolivegreen;
--color-bg: white; --color-bg: white;
--color-link: #337ab7; --color-link: #0000ee;
--color-linkhbg: #e6f0ff;
--color-linkh: #002266;
--color-bq: olivedrab;
--color-notice: #fb4f4f; --color-notice: #fb4f4f;
} }
@@ -19,11 +16,18 @@
html { html {
scrollbar-gutter: stable; scrollbar-gutter: stable;
scroll-behavior: smooth;
font-size: 14pt; font-size: 14pt;
} }
body { body {
font-family: 'Lato', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; font-family:
"Lato",
-apple-system,
BlinkMacSystemFont,
"PingFang SC",
"Microsoft YaHei",
sans-serif;
font-optical-sizing: auto; font-optical-sizing: auto;
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
@@ -32,30 +36,28 @@ body {
color: var(--color-text); color: var(--color-text);
background-color: var(--color-bg); background-color: var(--color-bg);
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} text-autospace: no-autospace; /*using pangu.hs*/
body.lang-zh {
text-align: left;
} }
body a { body a {
color: var(--color-link);
text-decoration: none; text-decoration: none;
} }
.text-space a:hover { body a:hover {
background-color: var(--color-linkhbg); text-decoration: underline;
color: var(--color-linkh);
text-decoration: none;
} }
details { details {
background-color: var(--color-linkhbg); padding-left: 1em;
border: 2px solid var(--color-text);
} }
summary:hover { summary:hover {
cursor: pointer; cursor: pointer;
} }
/*mathML*/ /*mathML*/
.htmlmathparagraph, mtext,math { .htmlmathparagraph,
mtext,
math {
font-family: Lete Sans Math; font-family: Lete Sans Math;
} }
.math-container, .math-container,
@@ -63,7 +65,7 @@ summary:hover {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
padding: .5em; padding: 0.5em;
} }
.math-container.math-container-tagged { .math-container.math-container-tagged {
@@ -72,7 +74,7 @@ summary:hover {
align-items: center; align-items: center;
column-gap: 1rem; column-gap: 1rem;
overflow: visible; overflow: visible;
padding: .5em 0; padding: 0.5em 0;
} }
.math-container.math-container-tagged .math-tag-spacer { .math-container.math-container-tagged .math-tag-spacer {
@@ -83,7 +85,7 @@ summary:hover {
min-width: 0; min-width: 0;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
padding: .5em 0; padding: 0.5em 0;
} }
.math-container.math-container-tagged .math-tag { .math-container.math-container-tagged .math-tag {
@@ -104,66 +106,32 @@ summary:hover {
font-variant-caps: small-caps; font-variant-caps: small-caps;
} }
p {
hyphens: auto;
}
a.url { a.url {
word-break: break-all; word-break: break-all;
} }
html body div.text-space main ul.post-list {
list-style-type: none;
padding-left: 1em;
}
/* top bar */
header { header {
font-weight: 400; font-weight: 400;
font-family: "IosevkaC", sans-serif; font-family: "IosevkaC", sans-serif;
} }
/* top bar*/
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.navright a {
margin: 0 0 0 1em;
}
/* Links inside the navbar */
.navbar a {
text-decoration: none;
color: var(--color-text);
}
.navbar a:visited {
color: var(--color-text);
}
nav {
text-align: right;
border-bottom: solid 1px var(--color-text);
}
nav a { nav a {
font-size: 1.2rem;
/*margin-left: 0.5em;*/
display: inline-block; display: inline-block;
vertical-align: middle;
text-decoration: none; text-decoration: none;
} }
.uri { .uri {
word-wrap: break-word; word-wrap: break-word;
/* Legacy support */
overflow-wrap: break-word; overflow-wrap: break-word;
/* Modern property */
word-break: break-all; word-break: break-all;
/* Break long words if necessary */
white-space: normal; white-space: normal;
/* Allow wrapping */
} }
footer { footer {
color: var(--color-text); color: var(--color-text);
font-size: 0.8rem; font-size: 0.8rem;
@@ -172,15 +140,6 @@ footer {
padding-right: 1em; padding-right: 1em;
} }
h1,
h2,
h3,
h4,
h5,
h6 {
text-align: left;
}
.pagetitle { .pagetitle {
font-size: 2rem; font-size: 2rem;
font-weight: normal; font-weight: normal;
@@ -194,22 +153,20 @@ h1 {
font-size: 1.44rem; font-size: 1.44rem;
font-weight: bold; font-weight: bold;
font-style: normal; font-style: normal;
text-align: left;
line-height: 100%;
} }
h2 { h2 {
margin-top: 1em; margin-top: 1em;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bold; font-weight: bold;
font-style: normal font-style: normal;
} }
h3 { h3 {
margin-top: 1em; margin-top: 1em;
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
font-style: normal font-style: normal;
} }
article .header { article .header {
@@ -219,14 +176,7 @@ article .header {
text-align: left; text-align: left;
} }
.info,
.info {
color: var(--color-tag2);
font-size: 1rem;
font-style: normal;
text-align: left;
}
.info a { .info a {
color: var(--color-tag2); color: var(--color-tag2);
font-size: 1rem; font-size: 1rem;
@@ -247,33 +197,45 @@ section.body {
line-height: normal; line-height: normal;
} }
blockquote { /* table. copied from https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table */
margin: 1rem 0; table {
padding: 0 0 0 1.5em; border-collapse: collapse;
border-left: 3px solid var(--color-bq); border: 2px solid rgb(140 140 140);
font-size: 0.8rem;
letter-spacing: 1px;
} }
blockquote p { caption {
margin: 0; caption-side: bottom;
padding: 10px;
font-weight: bold;
} }
ol { thead,
padding-left: 2em; tfoot {
} background-color: rgb(228 240 245);
ul {
list-style-type: square;
padding-left: 2em;
}
li {
margin-bottom: 0.15em;
} }
table,
th, th,
td { td {
border: 1px solid darkolivegreen; border: 1px solid rgb(160 160 160);
border-collapse: collapse; padding: 8px 10px;
text-align: left; }
td:last-of-type {
text-align: center;
}
tbody > tr:nth-of-type(even) {
background-color: rgb(237 238 242);
}
tfoot th {
text-align: right;
}
tfoot td {
font-weight: bold;
} }
figure { figure {
@@ -284,23 +246,11 @@ figure {
max-width: 80%; max-width: 80%;
} }
figcaption {
/* font: italic smaller sans-serif; */
padding: 3px;
text-align: center;
}
.caption {
display: none
}
.centerimg img { .centerimg img {
margin: 0 auto 0 auto; margin: 0 auto 0 auto;
display: block; display: block;
} }
div.highlight, div.highlight,
pre code { pre code {
margin: auto; margin: auto;
@@ -320,18 +270,16 @@ code {
text-rendering: optimizeSpeed; text-rendering: optimizeSpeed;
} }
.draft-notice { .draft-notice {
color: var(--color-notice); color: var(--color-notice);
margin: 1em auto; margin: 1em auto;
text-align: center text-align: center;
} }
.subtitle { .subtitle {
text-align: left; text-align: left;
font-size: 1.2rem; font-size: 1.2rem;
margin-top: 0 margin-top: 0;
} }
.gallery { .gallery {
margin-top: 2em; margin-top: 2em;
@@ -401,7 +349,7 @@ code {
padding-left: 1em; padding-left: 1em;
line-height: 1.2; line-height: 1.2;
list-style-type: decimal; list-style-type: decimal;
margin-left: 0 margin-left: 0;
} }
div#contents ul.notes-list, div#contents ul.notes-list,
@@ -414,8 +362,8 @@ code {
list-style-type: none; list-style-type: none;
} }
div#contents-big li+li { div#contents-big li + li {
margin-top: 0.5em margin-top: 0.5em;
} }
div#contents-big { div#contents-big {
@@ -428,7 +376,7 @@ code {
margin-right: 4em; margin-right: 4em;
position: sticky; position: sticky;
top: 5rem; top: 5rem;
left: 100% left: 100%;
} }
div#contents-big .mini-header { div#contents-big .mini-header {
@@ -452,7 +400,6 @@ code {
} }
@media print { @media print {
.no-print, .no-print,
.no-print * { .no-print * {
display: none !important; display: none !important;
-1
View File
@@ -1,4 +1,3 @@
/* fonts */ /* fonts */
@font-face { @font-face {
+1
View File
@@ -33,6 +33,7 @@ executable hakysidian
, filepath , filepath
, process , process
, time , time
, unix
, wai-app-static , wai-app-static
, warp , warp
-- , ghc-syntax-highlighter -- , ghc-syntax-highlighter
+31 -7
View File
@@ -1,3 +1,14 @@
# Drawbacks
- currently all shared files (css, templates, csl files...) are stored in `~/.cabal/store/`. there will be a copy for every compile
- web preview needs a port. if you don't set port manually, you cannot preview two projects at the same time.
--------
# hakysidian # hakysidian
`hakysidian` is a static site generator for note projects. `hakysidian` is a static site generator for note projects.
@@ -54,7 +65,7 @@ cabal install exe:hakysidian
## Commands ## Commands
The CLI mirrors the common Hakyll workflow: The default CLI mirrors the common Hakyll workflow:
```bash ```bash
hakysidian build hakysidian build
@@ -70,28 +81,41 @@ hakysidian watch --host 127.0.0.1 --port 8000
hakysidian watch --no-server hakysidian watch --no-server
``` ```
The dashboard is now an explicit TUI mode:
```bash
hakysidian -tui
hakysidian -tui --host 127.0.0.1 --port 8000
hakysidian -tui --no-server
```
What each command does: What each command does:
- `build`: incremental site build. - `build`: incremental site build.
- `clean`: removes generated output and cache. - `clean`: removes generated output and cache.
- `rebuild`: clears output/cache and builds from scratch. - `rebuild`: clears output/cache and builds from scratch.
- `watch`: shows an in-place terminal dashboard, watches project inputs, and rebuilds automatically on change. - `watch`: runs Hakyll's normal watch workflow, prints build logs directly to the terminal, and rebuilds automatically on change.
- `-tui`: starts the interactive dashboard with explicit controls for watching and cleaning.
## Watch Mode ## Watch And TUI
`watch` tracks: Both `watch` and `-tui` work against the same project inputs:
- `notes/**` - `notes/**`
- `reference.bib` - `reference.bib`
- `math-macros.md` - `math-macros.md`
- `images/**` - `images/**`
The watch UI: Normal `watch` behaves like a standard Hakyll watch command: it stays in the terminal, rebuilds when inputs change, and can start a preview server unless `--no-server` is passed.
`-tui` uses an alternate-screen dashboard that:
- uses the terminals current size to keep the dashboard within the visible screen, - uses the terminals current size to keep the dashboard within the visible screen,
- keeps recent build output in a bounded activity pane, - keeps recent build output in a bounded activity pane,
- avoids scrolling raw Hakyll logs through the terminal, - can start a local preview server unless `--no-server` is passed,
- can start a local preview server unless `--no-server` is passed. - supports `w` to start watching, `s` to stop watching, `c` to clean, and `q` to quit.
The TUI requires an interactive terminal.
## Notes Format ## Notes Format
+255 -77
View File
@@ -4,13 +4,12 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneKindSignatures #-} {-# LANGUAGE StandaloneKindSignatures #-}
{-# LANGUAGE ViewPatterns #-}
import ChaoDoc import ChaoDoc
import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent (forkIO, threadDelay)
import Control.Exception (SomeException, bracket_, try) import Control.Exception (SomeException, bracket_, try)
import Control.Monad (filterM, unless, void) import Control.Monad (filterM, unless, void, when)
import Data.Char (isSpace) import Data.Char (isSpace, toLower)
import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Kind (Type) import Data.Kind (Type)
import Data.List (intercalate, isPrefixOf, sort, sortOn) import Data.List (intercalate, isPrefixOf, sort, sortOn)
@@ -23,6 +22,7 @@ import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (getZonedTime) import Data.Time.LocalTime (getZonedTime)
import Hakyll import Hakyll
import Hakyll.Core.Runtime (RunMode (RunModeNormal)) import Hakyll.Core.Runtime (RunMode (RunModeNormal))
import Network.Wai.Application.Static (staticApp)
import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp as Warp
import qualified Paths_hakysidian as Paths import qualified Paths_hakysidian as Paths
import System.Directory import System.Directory
@@ -31,19 +31,32 @@ import System.Directory
doesFileExist, doesFileExist,
getCurrentDirectory, getCurrentDirectory,
getModificationTime, getModificationTime,
listDirectory listDirectory,
) )
import System.Environment (getArgs, getExecutablePath, lookupEnv) import System.Environment (getArgs, getExecutablePath, lookupEnv)
import System.Exit (ExitCode (..), die, exitSuccess, exitWith) import System.Exit (ExitCode (..), die, exitSuccess, exitWith)
import System.FilePath import System.FilePath
import Network.Wai.Application.Static (staticApp)
import System.IO import System.IO
( BufferMode (NoBuffering), ( BufferMode (NoBuffering),
hFlush, hFlush,
hGetBuffering, hGetBuffering,
hGetChar,
hIsTerminalDevice, hIsTerminalDevice,
hSetBuffering, hSetBuffering,
stdout hWaitForInput,
stdin,
stdout,
)
import System.Posix.IO (stdInput)
import System.Posix.Terminal
( TerminalAttributes,
TerminalMode (EnableEcho, ProcessInput),
TerminalState (Immediately),
getTerminalAttributes,
setTerminalAttributes,
withMinInput,
withTime,
withoutMode,
) )
import System.Process (CreateProcess (cwd), proc, readCreateProcessWithExitCode) import System.Process (CreateProcess (cwd), proc, readCreateProcessWithExitCode)
import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate) import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate)
@@ -99,11 +112,24 @@ data CliCommand
| CleanCommand | CleanCommand
| HelpCommand | HelpCommand
| RebuildCommand | RebuildCommand
| TuiCommand WatchSettings
| WatchCommand WatchSettings | WatchCommand WatchSettings
type TuiAction :: Type
data TuiAction
= TuiClean
| TuiQuit
| TuiStartWatching
| TuiStopWatching
type FileSnapshot :: Type type FileSnapshot :: Type
type FileSnapshot = M.Map FilePath UTCTime type FileSnapshot = M.Map FilePath UTCTime
type TuiWatchState :: Type
data TuiWatchState
= TuiWatchStopped
| TuiWatching FileSnapshot
type ServerStatus :: Type type ServerStatus :: Type
data ServerStatus data ServerStatus
= ServerDisabled = ServerDisabled
@@ -190,14 +216,17 @@ main = do
Right RebuildCommand -> do Right RebuildCommand -> do
validateProject projectRoot validateProject projectRoot
exitWith =<< runSiteCommand config rebuildOptions cslPath exitWith =<< runSiteCommand config rebuildOptions cslPath
Right (TuiCommand watchSettings) -> do
validateProject projectRoot
exitWith =<< runTui projectRoot config cslPath watchSettings
Right (WatchCommand watchSettings) -> do Right (WatchCommand watchSettings) -> do
validateProject projectRoot validateProject projectRoot
exitWith =<< runWatch projectRoot config cslPath watchSettings exitWith =<< runSiteCommand config (watchOptions watchSettings) cslPath
usageText :: String usageText :: String
usageText = usageText =
unlines unlines
[ "usage: hakysidian [build|clean|rebuild|watch [--host HOST] [--port PORT] [--no-server]]", [ "usage: hakysidian [build|clean|rebuild|watch [--host HOST] [--port PORT] [--no-server]|-tui [--host HOST] [--port PORT] [--no-server]]",
"", "",
"Run inside a project directory containing notes/, reference.bib, math-macros.md, and optional images/." "Run inside a project directory containing notes/, reference.bib, math-macros.md, and optional images/."
] ]
@@ -210,6 +239,8 @@ parseCliCommand config args
["build"] -> Right BuildCommand ["build"] -> Right BuildCommand
["clean"] -> Right CleanCommand ["clean"] -> Right CleanCommand
["rebuild"] -> Right RebuildCommand ["rebuild"] -> Right RebuildCommand
"-tui" : rest -> Right (TuiCommand (parseWatchSettings config rest))
"--tui" : rest -> Right (TuiCommand (parseWatchSettings config rest))
"watch" : rest -> Right (WatchCommand (parseWatchSettings config rest)) "watch" : rest -> Right (WatchCommand (parseWatchSettings config rest))
command : _ -> Left ("Unknown command: " <> command) command : _ -> Left ("Unknown command: " <> command)
@@ -232,18 +263,18 @@ validateProject projectRoot = do
unless (null missing) $ unless (null missing) $
die $ die $
unlines $ unlines $
"hakysidian is missing required project inputs:" : "hakysidian is missing required project inputs:"
map (" - " ++) missing : map (" - " ++) missing
initialDashboardState :: DashboardState initialDashboardState :: DashboardState
initialDashboardState = initialDashboardState =
DashboardState DashboardState
{ dashboardStatus = "starting", { dashboardStatus = "idle",
dashboardLastChange = "waiting for first build", dashboardLastChange = "press w to start watching",
dashboardLastBuild = "pending", dashboardLastBuild = "no command run yet",
dashboardLogLines = dashboardLogLines =
[ "watcher ready", [ "tui ready",
"watching notes/, reference.bib, math-macros.md, images/ (optional)" "controls: w watch, s stop, c clean, q quit"
] ]
} }
@@ -289,31 +320,62 @@ extractOptionValue option = go
withWatchTui :: IO a -> IO a withWatchTui :: IO a -> IO a
withWatchTui action = do withWatchTui action = do
interactive <- hIsTerminalDevice stdout stdoutInteractive <- hIsTerminalDevice stdout
if interactive stdinInteractive <- hIsTerminalDevice stdin
if stdoutInteractive
then do then do
originalBuffering <- hGetBuffering stdout originalBuffering <- hGetBuffering stdout
originalInputBuffering <- hGetBuffering stdin
originalInputMode <-
if stdinInteractive
then Just <$> getTerminalAttributes stdInput
else pure Nothing
bracket_ bracket_
(do ( do
hSetBuffering stdout NoBuffering hSetBuffering stdout NoBuffering
when stdinInteractive do
hSetBuffering stdin NoBuffering
maybe
(pure ())
(\inputMode -> setTerminalAttributes stdInput (watchInputMode inputMode) Immediately)
originalInputMode
putStr "\ESC[?1049h\ESC[2J\ESC[H\ESC[?25l" putStr "\ESC[?1049h\ESC[2J\ESC[H\ESC[?25l"
hFlush stdout) hFlush stdout
(do )
( do
putStr "\ESC[0m\ESC[?25h\ESC[?1049l" putStr "\ESC[0m\ESC[?25h\ESC[?1049l"
hFlush stdout hFlush stdout
hSetBuffering stdout originalBuffering) maybe
(pure ())
(\inputMode -> setTerminalAttributes stdInput inputMode Immediately)
originalInputMode
when stdinInteractive do
hSetBuffering stdin originalInputBuffering
hSetBuffering stdout originalBuffering
)
action action
else action else action
watchInputMode :: TerminalAttributes -> TerminalAttributes
watchInputMode inputMode =
withTime
( withMinInput
( withoutMode
(withoutMode inputMode ProcessInput)
EnableEcho
)
1
)
0
renderWatchDashboard :: renderWatchDashboard ::
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
FilePath -> FilePath ->
Configuration ->
WatchSettings -> WatchSettings ->
IORef ServerStatus -> IORef ServerStatus ->
DashboardState -> DashboardState ->
IO () IO ()
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard = do renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef dashboard = do
terminalSize <- getTerminalSize terminalSize <- getTerminalSize
serverStatus <- readIORef serverStatusRef serverStatus <- readIORef serverStatusRef
previousRenderState <- readIORef renderStateRef previousRenderState <- readIORef renderStateRef
@@ -324,20 +386,22 @@ renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatu
border = "+" ++ replicate (cols - 2) '-' ++ "+" border = "+" ++ replicate (cols - 2) '-' ++ "+"
infoRows = infoRows =
[ dashboardRow cols ("Project : " ++ projectRoot), [ dashboardRow cols ("Project : " ++ projectRoot),
-- dashboardRow cols ("Output : " ++ destinationDirectory config),
dashboardRow cols ("Preview : " ++ renderServerStatus watchSettings serverStatus), dashboardRow cols ("Preview : " ++ renderServerStatus watchSettings serverStatus),
-- dashboardRow cols "Watch : notes/, reference.bib, math-macros.md, images/ (optional)", dashboardRow cols ("Change : " ++ dashboardLastChange dashboard),
-- dashboardRow cols ("Change : " ++ dashboardLastChange dashboard), dashboardRow cols ("Last op : " ++ dashboardLastBuild dashboard)
dashboardRow cols ("Build : " ++ dashboardLastBuild dashboard)
] ]
headerRows = headerRows =
[ border, [ border,
dashboardTitleRow cols "hakysidian watch" (dashboardStatus dashboard), dashboardTitleRow cols "hakysidian tui" (dashboardStatus dashboard),
border border
] ]
++ infoRows ++ infoRows
++ [border, dashboardRow cols "Recent activity", border] ++ [border, dashboardRow cols "Recent activity", border]
footerRows = [border] footerRows =
[ border,
dashboardRow cols "Controls: w watch, s stop, c clean, q quit, Ctrl-C interrupt",
border
]
availableLogRows = max 1 (rows - length headerRows - length footerRows) availableLogRows = max 1 (rows - length headerRows - length footerRows)
logRows = logRows =
map (dashboardRow cols) $ map (dashboardRow cols) $
@@ -351,7 +415,7 @@ renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatu
dashboardTitleRow :: Int -> String -> String -> String dashboardTitleRow :: Int -> String -> String -> String
dashboardTitleRow width leftText rightText = dashboardTitleRow width leftText rightText =
dashboardFramedRow width (leftText ++ spacer ++ clippedRight) dashboardRow width (leftText ++ spacer ++ clippedRight)
where where
usableWidth = max 1 (width - 4) usableWidth = max 1 (width - 4)
rightWidth = min (usableWidth `div` 3) (length rightText) rightWidth = min (usableWidth `div` 3) (length rightText)
@@ -366,10 +430,7 @@ dashboardTitleRow width leftText rightText =
| otherwise = replicate (max 1 (usableWidth - length clippedLeft - length clippedRight)) ' ' | otherwise = replicate (max 1 (usableWidth - length clippedLeft - length clippedRight)) ' '
dashboardRow :: Int -> String -> String dashboardRow :: Int -> String -> String
dashboardRow width = dashboardFramedRow width dashboardRow width content =
dashboardFramedRow :: Int -> String -> String
dashboardFramedRow width content =
"| " ++ padRight usableWidth (ellipsize usableWidth content) ++ " |" "| " ++ padRight usableWidth (ellipsize usableWidth content) ++ " |"
where where
usableWidth = max 1 (width - 4) usableWidth = max 1 (width - 4)
@@ -405,8 +466,8 @@ getTerminalSize = do
case sttySize of case sttySize of
Just terminalSize -> pure terminalSize Just terminalSize -> pure terminalSize
Nothing -> do Nothing -> do
rows <- maybe 24 id . (>>= readMaybe) <$> lookupEnv "LINES" rows <- fromMaybe 24 . (>>= readMaybe) <$> lookupEnv "LINES"
cols <- maybe 80 id . (>>= readMaybe) <$> lookupEnv "COLUMNS" cols <- fromMaybe 80 . (>>= readMaybe) <$> lookupEnv "COLUMNS"
pure (TerminalSize rows cols) pure (TerminalSize rows cols)
queryTerminalSize :: IO (Maybe TerminalSize) queryTerminalSize :: IO (Maybe TerminalSize)
@@ -415,7 +476,8 @@ queryTerminalSize = do
try $ try $
readCreateProcessWithExitCode readCreateProcessWithExitCode
(proc "sh" ["-c", "stty size </dev/tty"]) (proc "sh" ["-c", "stty size </dev/tty"])
"" :: IO (Either SomeException (ExitCode, String, String)) "" ::
IO (Either SomeException (ExitCode, String, String))
pure $ do pure $ do
(exitCode, stdoutText, _) <- either (const Nothing) Just result (exitCode, stdoutText, _) <- either (const Nothing) Just result
case exitCode of case exitCode of
@@ -430,6 +492,46 @@ queryTerminalSize = do
watchTimestamp :: IO String watchTimestamp :: IO String
watchTimestamp = formatTime defaultTimeLocale "%H:%M:%S" <$> getZonedTime watchTimestamp = formatTime defaultTimeLocale "%H:%M:%S" <$> getZonedTime
watchLoopDelayMicros :: Int
watchLoopDelayMicros = 1000000
watchInputPollMicros :: Int
watchInputPollMicros = 100000
waitForTuiAction :: Bool -> Int -> IO (Maybe TuiAction)
waitForTuiAction watchInputEnabled remainingMicros
| remainingMicros <= 0 = pure Nothing
| otherwise = do
nextAction <- pollTuiAction watchInputEnabled
case nextAction of
Just action -> pure (Just action)
Nothing -> do
threadDelay (min watchInputPollMicros remainingMicros)
waitForTuiAction watchInputEnabled (remainingMicros - watchInputPollMicros)
pollTuiAction :: Bool -> IO (Maybe TuiAction)
pollTuiAction watchInputEnabled
| not watchInputEnabled = pure Nothing
| otherwise = drainInput
where
drainInput = do
hasInput <- hWaitForInput stdin 0
if hasInput
then do
inputChar <- hGetChar stdin
case parseTuiAction inputChar of
Just action -> pure (Just action)
Nothing -> drainInput
else pure Nothing
parseTuiAction :: Char -> Maybe TuiAction
parseTuiAction inputChar = case toLower inputChar of
'c' -> Just TuiClean
'q' -> Just TuiQuit
's' -> Just TuiStopWatching
'w' -> Just TuiStartWatching
_ -> Nothing
trimTrailingSpace :: String -> String trimTrailingSpace :: String -> String
trimTrailingSpace = reverse . dropWhile isSpace . reverse trimTrailingSpace = reverse . dropWhile isSpace . reverse
@@ -467,47 +569,107 @@ cleanOptions = Options {verbosity = False, optCommand = Clean}
rebuildOptions :: Options rebuildOptions :: Options
rebuildOptions = Options {verbosity = False, optCommand = Rebuild} rebuildOptions = Options {verbosity = False, optCommand = Rebuild}
watchOptions :: WatchSettings -> Options
watchOptions watchSettings =
Options
{ verbosity = False,
optCommand =
Watch
{ host = watchHost watchSettings,
port = watchPort watchSettings,
no_server = not (watchServerEnabled watchSettings)
}
}
runSiteCommand :: Configuration -> Options -> FilePath -> IO ExitCode runSiteCommand :: Configuration -> Options -> FilePath -> IO ExitCode
runSiteCommand config options cslPath = runSiteCommand config options cslPath =
hakyllWithExitCodeAndArgs config options (siteRules cslPath) hakyllWithExitCodeAndArgs config options (siteRules cslPath)
runWatch :: FilePath -> Configuration -> FilePath -> WatchSettings -> IO ExitCode runTui :: FilePath -> Configuration -> FilePath -> WatchSettings -> IO ExitCode
runWatch projectRoot config _cslPath watchSettings = runTui projectRoot config _cslPath watchSettings = do
stdoutInteractive <- hIsTerminalDevice stdout
stdinInteractive <- hIsTerminalDevice stdin
let watchInputEnabled = stdoutInteractive && stdinInteractive
if watchInputEnabled
then
withWatchTui do withWatchTui do
serverStatusRef <- newIORef initialServerStatus serverStatusRef <- newIORef initialServerStatus
renderStateRef <- newIORef Nothing renderStateRef <- newIORef Nothing
startPreviewServer config watchSettings serverStatusRef startPreviewServer config watchSettings serverStatusRef
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef initialDashboardState renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef initialDashboardState
(_, initialDashboard) <- tuiLoop watchInputEnabled renderStateRef serverStatusRef TuiWatchStopped initialDashboardState
runWatchBuild else do
"build" putStrLn "hakysidian -tui requires an interactive terminal."
"initial build" pure (ExitFailure 1)
"initial build"
projectRoot
config
watchSettings
renderStateRef
serverStatusRef
initialDashboardState
initialSnapshot <- snapshotInputs projectRoot
watchLoop renderStateRef serverStatusRef initialSnapshot initialDashboard
where where
initialServerStatus initialServerStatus
| watchServerEnabled watchSettings = ServerStarting | watchServerEnabled watchSettings = ServerStarting
| otherwise = ServerDisabled | otherwise = ServerDisabled
watchLoop :: tuiLoop ::
Bool ->
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
IORef ServerStatus -> IORef ServerStatus ->
FileSnapshot -> TuiWatchState ->
DashboardState -> DashboardState ->
IO ExitCode IO ExitCode
watchLoop renderStateRef serverStatusRef previousSnapshot dashboard = do tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard = do
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef dashboard
threadDelay 1000000 nextAction <- waitForTuiAction watchInputEnabled watchLoopDelayMicros
case nextAction of
Just TuiQuit -> pure ExitSuccess
Just TuiStartWatching -> case watchState of
TuiWatching _ ->
tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard
TuiWatchStopped -> do
(_, nextDashboard) <-
runDashboardCommand
"rebuild"
"watch start"
"manual start"
"building (watch start)"
(watchCommandStatus "watch start")
projectRoot
watchSettings
renderStateRef
serverStatusRef
dashboard
nextSnapshot <- snapshotInputs projectRoot
tuiLoop watchInputEnabled renderStateRef serverStatusRef (TuiWatching nextSnapshot) nextDashboard
Just TuiStopWatching -> case watchState of
TuiWatchStopped ->
tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard
TuiWatching _ -> do
nextDashboard <-
appendDashboardMessage
( dashboard
{ dashboardStatus = "idle",
dashboardLastChange = "watch stopped"
}
)
"watch stopped"
tuiLoop watchInputEnabled renderStateRef serverStatusRef TuiWatchStopped nextDashboard
Just TuiClean -> do
(_, nextDashboard) <-
runDashboardCommand
"clean"
"clean"
"manual clean"
"cleaning"
cleanCommandStatus
projectRoot
watchSettings
renderStateRef
serverStatusRef
dashboard
tuiLoop watchInputEnabled renderStateRef serverStatusRef TuiWatchStopped nextDashboard
Nothing -> case watchState of
TuiWatchStopped ->
tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard
TuiWatching previousSnapshot -> do
nextSnapshot <- snapshotInputs projectRoot nextSnapshot <- snapshotInputs projectRoot
if nextSnapshot == previousSnapshot if nextSnapshot == previousSnapshot
then watchLoop renderStateRef serverStatusRef previousSnapshot dashboard then tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard
else do else do
let changedFiles = diffSnapshots previousSnapshot nextSnapshot let changedFiles = diffSnapshots previousSnapshot nextSnapshot
command :: String command :: String
@@ -517,52 +679,66 @@ runWatch projectRoot config _cslPath watchSettings =
else "build" else "build"
changeSummary = intercalate ", " changedFiles changeSummary = intercalate ", " changedFiles
(_, nextDashboard) <- (_, nextDashboard) <-
runWatchBuild runDashboardCommand
command command
command command
changeSummary changeSummary
("building (" ++ command ++ ")")
(watchCommandStatus command)
projectRoot projectRoot
config
watchSettings watchSettings
renderStateRef renderStateRef
serverStatusRef serverStatusRef
dashboard dashboard
watchLoop renderStateRef serverStatusRef nextSnapshot nextDashboard tuiLoop watchInputEnabled renderStateRef serverStatusRef (TuiWatching nextSnapshot) nextDashboard
watchCommandStatus :: String -> ExitCode -> String
watchCommandStatus label exitCode
| exitCode == ExitSuccess = "watching"
| otherwise = "watching after failed " ++ label
cleanCommandStatus :: ExitCode -> String
cleanCommandStatus exitCode
| exitCode == ExitSuccess = "idle"
| otherwise = "idle after failed clean"
renderBuildResult :: ExitCode -> String renderBuildResult :: ExitCode -> String
renderBuildResult ExitSuccess = "success" renderBuildResult ExitSuccess = "success"
renderBuildResult (ExitFailure code) = "failed (" ++ show code ++ ")" renderBuildResult (ExitFailure code) = "failed (" ++ show code ++ ")"
runWatchBuild :: appendDashboardMessage :: DashboardState -> String -> IO DashboardState
appendDashboardMessage dashboard message = do
timestamp <- watchTimestamp
pure (appendLogBatch dashboard message timestamp [])
runDashboardCommand ::
String -> String ->
String -> String ->
String -> String ->
String ->
(ExitCode -> String) ->
FilePath -> FilePath ->
Configuration ->
WatchSettings -> WatchSettings ->
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
IORef ServerStatus -> IORef ServerStatus ->
DashboardState -> DashboardState ->
IO (ExitCode, DashboardState) IO (ExitCode, DashboardState)
runWatchBuild command label changeSummary projectRoot config watchSettings renderStateRef serverStatusRef dashboard = do runDashboardCommand command label changeSummary runningStatus completedStatus projectRoot watchSettings renderStateRef serverStatusRef dashboard = do
startedAt <- watchTimestamp startedAt <- watchTimestamp
let runningDashboard = let runningDashboard =
dashboard dashboard
{ dashboardStatus = "building (" ++ label ++ ")", { dashboardStatus = runningStatus,
dashboardLastChange = changeSummary, dashboardLastChange = changeSummary,
dashboardLastBuild = "running since " ++ startedAt dashboardLastBuild = "running since " ++ startedAt
} }
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef runningDashboard renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef runningDashboard
(exitCode, buildLines) <- runCapturedSiteCommand projectRoot command (exitCode, buildLines) <- runCapturedSiteCommand projectRoot command
finishedAt <- watchTimestamp finishedAt <- watchTimestamp
let loggedDashboard = let loggedDashboard =
appendLogBatch runningDashboard (label ++ ": " ++ changeSummary) finishedAt buildLines appendLogBatch runningDashboard (label ++ ": " ++ changeSummary) finishedAt buildLines
completedDashboard = completedDashboard =
loggedDashboard loggedDashboard
{ dashboardStatus = { dashboardStatus = completedStatus exitCode,
if exitCode == ExitSuccess
then "watching"
else "watching after failed " ++ label,
dashboardLastBuild = dashboardLastBuild =
renderBuildResult exitCode renderBuildResult exitCode
++ " at " ++ " at "
@@ -570,7 +746,7 @@ runWatchBuild command label changeSummary projectRoot config watchSettings rende
++ " via " ++ " via "
++ label ++ label
} }
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef completedDashboard renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef completedDashboard
pure (exitCode, completedDashboard) pure (exitCode, completedDashboard)
startPreviewServer :: Configuration -> WatchSettings -> IORef ServerStatus -> IO () startPreviewServer :: Configuration -> WatchSettings -> IORef ServerStatus -> IO ()
@@ -580,10 +756,11 @@ startPreviewServer config watchSettings serverStatusRef
forkIO $ forkIO $
do do
result <- result <-
(try $ ( try $
Warp.runSettings settings $ Warp.runSettings settings $
staticApp $ staticApp $
previewSettings config (destinationDirectory config)) :: previewSettings config (destinationDirectory config)
) ::
IO (Either SomeException ()) IO (Either SomeException ())
case result of case result of
Left err -> writeIORef serverStatusRef (ServerFailed (show err)) Left err -> writeIORef serverStatusRef (ServerFailed (show err))
@@ -593,7 +770,8 @@ startPreviewServer config watchSettings serverStatusRef
settings = settings =
Warp.setBeforeMainLoop (writeIORef serverStatusRef ServerRunning) $ Warp.setBeforeMainLoop (writeIORef serverStatusRef ServerRunning) $
Warp.setPort (watchPort watchSettings) $ Warp.setPort (watchPort watchSettings) $
Warp.setHost (fromString (watchHost watchSettings)) $ Warp.setHost
(fromString (watchHost watchSettings))
Warp.defaultSettings Warp.defaultSettings
snapshotInputs :: FilePath -> IO FileSnapshot snapshotInputs :: FilePath -> IO FileSnapshot
@@ -625,8 +803,8 @@ trackedFilesIn root = do
if exists if exists
then do then do
entries <- listDirectory root entries <- listDirectory root
fmap concat $ fmap concat
traverse <$> traverse
( \name -> do ( \name -> do
let path = root </> name let path = root </> name
isDir <- doesDirectoryExist path isDir <- doesDirectoryExist path