Compare commits

...
Sign in to create a new pull request.

26 commits

Author SHA1 Message Date
3f8057490f Commit unstaged changes
Coming back to this project after a while, these changes were still unstaged. In
order not to lose them I'm committing them here, even though I don't remember
what they're for. They might not even work properly.
2020-06-19 13:51:59 +00:00
a3ed8012b2 [web] Fix editor line sometimes not being curved 2020-03-25 21:13:34 +00:00
53b4b2c9a0 [tui] Clean up and add more key bindings 2020-03-25 21:13:34 +00:00
c2b4a23542 [tui] Clean up the UI state 2020-03-25 21:13:34 +00:00
68b1129a49 [common] Clean up node module and add some useful functions 2020-03-25 21:13:33 +00:00
60c61974fb [tui] Move cursor to newly created nodes 2020-03-25 21:13:15 +00:00
54795b81ac [web] Connect to server
The individual components are more-or-less working, but the code that glues them
together is still pretty ugly. I should probably revisit and clean up the
individual components too. Also, the cursor code is missing a few features, but
everything is usable for the first time :D
2020-03-25 21:13:15 +00:00
78235ef7cf [cabin] Parse command-line options 2020-03-25 21:13:14 +00:00
63a36d8a71 [server] Extract shared editing logic into a branch 2020-03-25 21:12:57 +00:00
aa074d181b [server] Rename "graft" to "draw" 2020-03-25 21:12:34 +00:00
83406dff10 [server] Reimplement collaborative editing 2020-03-25 21:12:32 +00:00
a2d392bc4d [server] Remove old tree modules 2020-03-25 21:12:09 +00:00
f6a281fee1 [server] Add schema for tree-like node structures 2020-03-25 21:11:38 +00:00
cdfe515df6 [server] Add new structure for server applications 2020-03-25 21:11:25 +00:00
04b8bd7445 [all] Include summary of subprojects in readme 2020-03-25 21:11:03 +00:00
56373a0748 [stack] Update resolver to lts-15.3 2020-03-25 21:11:03 +00:00
f8fd5b3c3e [web] Move web client to this repo 2020-03-25 21:11:03 +00:00
4b8d0ee4a4 [all] Reorganize haskell code into multiple packages 2020-03-25 21:10:59 +00:00
0edc241149 [client] Adjust editor to look like web client text boxes 2020-03-14 01:12:21 +00:00
d58f1e4fef [client] Fix rendering of nodes containing empty strings 2020-02-28 19:35:42 +00:00
041f117df8 [client] Allow choosing the attribute name for the indentation text 2020-02-28 19:28:44 +00:00
0d01e4792d [client] Remove microlens dependency 2020-02-28 19:25:31 +00:00
50e78cfed3 [client] Use the correct function for the job
Somehow, I missed this function and reimplemented it myself. Sometimes it helps
to read the documentation...
2020-02-28 19:25:31 +00:00
ab8c764329 [client] Align node permissions to the right
The previous layout depended on txtWrap being greedy, but not taking up all
available horizontal space. That behaviour is incorrect according to the
definition of greedy widgets, which have to take up all available horizontal
space.

In brick 0.52, this behaviour has been partially fixed. The padRight function
added in this commit emulates the correct txtWrap behaviour even for cases where
txtWrap has not been fixed yet. If txtWrap is fixed entirely, the padRight can
be removed again.
2020-02-27 15:09:11 +00:00
22974d96a7 [server] Move server-related files into their own subdirectory 2020-02-26 08:57:09 +00:00
a2c2c4487b [client] Improve cursor behaviour when elements are deleted 2020-02-26 08:57:09 +00:00
51 changed files with 2163 additions and 912 deletions

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
.stack-work/
forest.cabal
*~

View file

@ -1,4 +1,4 @@
# Changelog for forest
## upcoming
* create project
- create project

View file

@ -1,5 +1,19 @@
# forest
Forest is an experiment in tree-based interaction.
Forest is an experiment in tree-based interaction: One or more clients connect
to a server and interact with it (and each other) via an interface consisting of
text-based nodes forming a tree.
The project is split into multiple subprojects, most of which are Haskell
packages. For more information on individual subprojects, see their README or
the summary below.
[API documentation](docs/API.md)
## Subprojects
- [forest-cabin](forest-cabin/): Server (Haskell)
- [forest-common](forest-common/): Common types and functions (Haskell)
- [forest-server](forest-server/): Server framework (Haskell)
- [forest-tui](forest-tui/): Terminal-based client (Haskell)
- [forest-web](forest-web/): Web-based client (static site)

View file

@ -1,2 +0,0 @@
import Distribution.Simple
main = defaultMain

1
forest-cabin/README.md Normal file
View file

@ -0,0 +1 @@
# forest-cabin

114
forest-cabin/app/Main.hs Normal file
View file

@ -0,0 +1,114 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
module Main where
import Control.Concurrent.STM
import Lens.Micro
import Lens.Micro.TH
import qualified Network.WebSockets as WS
import Options.Applicative
import Forest.Node
import Forest.Server.Branch.SharedEdit
import Forest.Server.Schema
import Forest.Server.TreeApp
{- Command line options -}
data ServerOptions = ServerOptions
{ serverPingDelay :: Int
, serverHost :: String
, serverPort :: Int
}
parser :: Parser ServerOptions
parser = ServerOptions
<$> option auto
( long "ping-delay"
<> help "How many seconds to wait between each ping sent to the client"
<> value 10
<> showDefault
<> metavar "SECONDS"
)
<*> strOption
( short 'h'
<> long "host"
<> help "The server's host"
<> value (WS.serverHost WS.defaultServerOptions)
<> showDefault
<> metavar "HOST"
)
<*> option auto
( short 'p'
<> long "port"
<> help "The port to listen for websocket connections on"
<> value (WS.serverPort WS.defaultServerOptions)
<> showDefault
<> metavar "PORT"
)
serverOptionsParserInfo :: ParserInfo ServerOptions
serverOptionsParserInfo = info (helper <*> parser) fullDesc
wsOptions :: ServerOptions -> WS.ServerOptions
wsOptions o = WS.defaultServerOptions
{ WS.serverHost = serverHost o
, WS.serverPort = serverPort o
, WS.serverRequirePong = Just $ serverPingDelay o * 2
}
{- The actual app -}
data AppEvent = UpdateSharedEdit
deriving (Show, Eq)
newtype AppState = AppState
{ _asSharedEdit :: SharedEditLocal
}
makeLenses ''AppState
schema :: AppState -> Schema (Branch AppState AppEvent)
schema s = fork' "Forest"
[ leaf $ schemaLift asSharedEdit sharedEditBranch s
]
draw :: AppState -> Node
draw = schemaDraw . schema
handleEvent :: AppState -> Event AppEvent -> IO (Next AppState)
handleEvent s (Custom UpdateSharedEdit) = do
sel' <- sharedEditUpdate $ s ^. asSharedEdit
pure $ continue $ s & asSharedEdit .~ sel'
handleEvent s e = case schemaHandleEvent (schema s) e of
Nothing -> pure $ continue s
Just s' -> continue <$> s'
constructor
:: TChan AppEvent
-> SharedEditGlobal
-> (AppState -> Maybe (TChan AppEvent) -> IO a)
-> IO a
constructor broadcastChan seg cont = do
sel <- sharedEditLocal seg
receiveChan <- atomically $ dupTChan broadcastChan
cont (AppState sel) (Just receiveChan)
main :: IO ()
main = do
opts <- execParser serverOptionsParserInfo
putStrLn "Preparing server"
broadcastChan <- atomically newBroadcastTChan
let onEditChange = atomically $ writeTChan broadcastChan UpdateSharedEdit
seg <- sharedEditGlobal onEditChange "Sandbox"
let app = TreeApp
{ appDraw = draw
, appHandleEvent = handleEvent
, appConstructor = constructor broadcastChan seg
}
putStrLn "Starting server"
WS.runServerWithOptions (wsOptions opts) $
runTreeApp (serverPingDelay opts) app

View file

@ -0,0 +1,60 @@
cabal-version: 1.12
-- This file has been generated from package.yaml by hpack version 0.31.2.
--
-- see: https://github.com/sol/hpack
--
-- hash: c619b22393d818639b183c69031b267a4ed16faeaf609a75ef1cadb9288195e1
name: forest-cabin
version: 0.1.0.0
synopsis: A forest server hosted at forest.plugh.de
description: Please see the README at <https://github.com/Garmelon/forest#readme>
homepage: https://github.com/Garmelon/forest#readme
bug-reports: https://github.com/Garmelon/forest/issues
author: Garmelon <joscha@plugh.de>
maintainer: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
license: MIT
build-type: Simple
extra-source-files:
README.md
source-repository head
type: git
location: https://github.com/Garmelon/forest
library
other-modules:
Paths_forest_cabin
hs-source-dirs:
src
build-depends:
base >=4.7 && <5
, forest-common
, forest-server
, microlens
, microlens-th
, optparse-applicative
, stm
, websockets
default-language: Haskell2010
executable forest-cabin
main-is: Main.hs
other-modules:
Paths_forest_cabin
hs-source-dirs:
app
ghc-options: -threaded -rtsopts -with-rtsopts=-N
build-depends:
base >=4.7 && <5
, forest-cabin
, forest-common
, forest-server
, microlens
, microlens-th
, optparse-applicative
, stm
, websockets
default-language: Haskell2010

36
forest-cabin/package.yaml Normal file
View file

@ -0,0 +1,36 @@
name: forest-cabin
version: 0.1.0.0
license: MIT
author: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
synopsis: A forest server hosted at forest.plugh.de
description: Please see the README at <https://github.com/Garmelon/forest#readme>
github: Garmelon/forest
extra-source-files:
- README.md
dependencies:
- base >= 4.7 && < 5
- forest-common
- forest-server
- microlens
- microlens-th
- optparse-applicative
- stm
- websockets
library:
source-dirs: src
executables:
forest-cabin:
source-dirs: app
main: Main.hs
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- forest-cabin

1
forest-common/README.md Normal file
View file

@ -0,0 +1 @@
# forest-common

View file

@ -0,0 +1,45 @@
cabal-version: 1.12
-- This file has been generated from package.yaml by hpack version 0.31.2.
--
-- see: https://github.com/sol/hpack
--
-- hash: e59723e563cf364a74b1032409ed7a9d3ecbec3a6baa34554771cb5c1a5689d9
name: forest-common
version: 0.1.0.0
synopsis: A tree-based multi-user interaction thing
description: Please see the README at <https://github.com/Garmelon/forest#readme>
homepage: https://github.com/Garmelon/forest#readme
bug-reports: https://github.com/Garmelon/forest/issues
author: Garmelon <joscha@plugh.de>
maintainer: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
license: MIT
build-type: Simple
extra-source-files:
README.md
source-repository head
type: git
location: https://github.com/Garmelon/forest
library
exposed-modules:
Forest.Api
Forest.Node
Forest.OrderedMap
Forest.Util
other-modules:
Paths_forest_common
hs-source-dirs:
src
build-depends:
aeson
, async
, base >=4.7 && <5
, containers
, safe
, text
, websockets
default-language: Haskell2010

View file

@ -0,0 +1,24 @@
name: forest-common
version: 0.1.0.0
license: MIT
author: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
synopsis: A tree-based multi-user interaction thing
description: Please see the README at <https://github.com/Garmelon/forest#readme>
github: Garmelon/forest
extra-source-files:
- README.md
dependencies:
- base >= 4.7 && < 5
- aeson
- async
- containers
- safe
- text
- websockets
library:
source-dirs: src

View file

@ -2,7 +2,9 @@
{-# LANGUAGE OverloadedStrings #-}
module Forest.Node
( NodeId
(
-- * Nodes
NodeId
, enumerateIds
, findUnusedId
, NodeFlags(..)
@ -11,14 +13,27 @@ module Forest.Node
, newNode
, txtNode
, hasChildren
, mapChildren
, diffNodes
, flatten
-- ** Traversing the tree
, applyId
, applyPath
, firstChild
, lastChild
, firstSibling
, prevSibling
, nextSibling
, lastSibling
, firstNode
, prevNode
, nextNode
, lastNode
-- ** Modifying at a path
, adjustAt
, replaceAt
, deleteAt
, appendAt
, diffNodes
-- * Paths
, Path(..)
, referencedNodeExists
, splitHeadTail
@ -34,14 +49,21 @@ import qualified Data.Map.Strict as Map
import Data.Maybe
import qualified Data.Set as Set
import qualified Data.Text as T
import Safe
import qualified Forest.OrderedMap as OMap
import Forest.Util
{- Nodes -}
type NodeId = T.Text
-- | An infinite list of 'NodeId's. Does *not* contain every possible 'NodeId'.
enumerateIds :: [NodeId]
enumerateIds = map (T.pack . show) [(0::Integer)..]
-- | Find a 'NodeId' that is not contained in the given set of IDs. Returns the
-- first matching ID from 'enumerateIds'.
findUnusedId :: Set.Set NodeId -> NodeId
findUnusedId usedIds =
head $ filter (\nid -> not $ nid `Set.member` usedIds) enumerateIds
@ -82,7 +104,7 @@ data Node = Node
{ nodeText :: !T.Text
, nodeFlags :: !NodeFlags
, nodeChildren :: !(OMap.OrderedMap NodeId Node)
} deriving (Show)
} deriving (Show, Eq)
instance ToJSON Node where
toJSON node = object
@ -140,14 +162,87 @@ txtNode flags text = newNode flags text []
hasChildren :: Node -> Bool
hasChildren = not . OMap.null . nodeChildren
mapChildren :: (NodeId -> Node -> a) -> Node -> [a]
mapChildren f = map (uncurry f) . OMap.toList . nodeChildren
diffNodes :: Node -> Node -> Maybe (Path, Node)
diffNodes a b
| nodesDiffer || childrenChanged = Just (Path [], b)
| otherwise = case differingChildren of
[] -> Nothing
[(x, Path xs, node)] -> Just (Path (x:xs), node)
_ -> Just (Path [], b)
where
nodesDiffer = nodeText a /= nodeText b || nodeFlags a /= nodeFlags b
aChildren = nodeChildren a
bChildren = nodeChildren b
childrenChanged = OMap.keys aChildren /= OMap.keys bChildren
diffedChildren = Map.toList $ Map.intersectionWith diffNodes (OMap.toMap aChildren) (OMap.toMap bChildren)
differingChildren = [(key, path, node) | (key, Just (path, node)) <- diffedChildren]
applyId :: NodeId -> Node -> Maybe Node
applyId nid node = nodeChildren node OMap.!? nid
-- | Return the 'Path's to a node and its subnodes in the order they would be
-- displayed in.
flatten :: Node -> [Path]
flatten node = Path [] : flattenedChildren
where
flattenChild nid n = map (Path [nid] <>) (flatten n)
flattenedChildren =
concat $ OMap.elems $ OMap.mapWithKey flattenChild $ nodeChildren node
applyPath :: Path -> Node -> Maybe Node
applyPath (Path ids) node = foldM (flip applyId) node ids
{- Traversing the tree -}
applyId :: Node -> NodeId -> Maybe Node
applyId node nid = nodeChildren node OMap.!? nid
applyPath :: Node -> Path -> Maybe Node
applyPath node (Path ids) = foldM applyId node ids
getChild :: ([NodeId] -> Maybe NodeId) -> Node -> Path -> Maybe Path
getChild f root path = do
node <- applyPath root path
let childIds = OMap.keys $ nodeChildren node
childId <- f childIds
pure $ path <> Path [childId]
firstChild :: Node -> Path -> Maybe Path
firstChild = getChild headMay
lastChild :: Node -> Path -> Maybe Path
lastChild = getChild lastMay
getSibling :: (NodeId -> [NodeId] -> Maybe NodeId) -> Node -> Path -> Maybe Path
getSibling f root path = do
(parentPath, nodeId) <- splitInitLast path
parentNode <- applyPath root parentPath
let siblingIds = OMap.keys $ nodeChildren parentNode
siblingId <- f nodeId siblingIds
pure $ parentPath <> Path [siblingId]
firstSibling :: Node -> Path -> Maybe Path
firstSibling = getSibling $ const headMay
prevSibling :: Node -> Path -> Maybe Path
prevSibling = getSibling $ findPrev . (==)
nextSibling :: Node -> Path -> Maybe Path
nextSibling = getSibling $ findNext . (==)
lastSibling :: Node -> Path -> Maybe Path
lastSibling = getSibling $ const lastMay
getNode :: (Path -> [Path] -> Maybe Path) -> Node -> Path -> Maybe Path
getNode f root path = f path $ flatten root
firstNode :: Node -> Path -> Maybe Path
firstNode = getNode $ const headMay
prevNode :: Node -> Path -> Maybe Path
prevNode = getNode $ findPrev . (==)
nextNode :: Node -> Path -> Maybe Path
nextNode = getNode $ findNext . (==)
lastNode :: Node -> Path -> Maybe Path
lastNode = getNode $ const lastMay
{- Modifying at a path -}
adjustAt :: (Node -> Node) -> Path -> Node -> Node
adjustAt f (Path []) node = f node
@ -176,27 +271,14 @@ appendAt node =
let nid = findUnusedId $ OMap.keysSet m
in OMap.append nid node m
diffNodes :: Node -> Node -> Maybe (Path, Node)
diffNodes a b
| nodesDiffer || childrenChanged = Just (Path [], b)
| otherwise = case differingChildren of
[] -> Nothing
[(x, Path xs, node)] -> Just (Path (x:xs), node)
_ -> Just (Path [], b)
where
nodesDiffer = nodeText a /= nodeText b || nodeFlags a /= nodeFlags b
aChildren = nodeChildren a
bChildren = nodeChildren b
childrenChanged = OMap.keys aChildren /= OMap.keys bChildren
diffedChildren = Map.toList $ Map.intersectionWith diffNodes (OMap.toMap aChildren) (OMap.toMap bChildren)
differingChildren = [(key, path, node) | (key, Just (path, node)) <- diffedChildren]
{- Paths -}
newtype Path = Path
{ pathElements :: [NodeId]
} deriving (Show, Eq, Ord, Semigroup, Monoid, ToJSON, FromJSON)
referencedNodeExists :: Node -> Path -> Bool
referencedNodeExists node path = isJust $ applyPath path node
referencedNodeExists node path = isJust $ applyPath node path
splitHeadTail :: Path -> Maybe (NodeId, Path)
splitHeadTail (Path []) = Nothing

View file

@ -65,11 +65,17 @@ import qualified Data.Set as Set
data OrderedMap k a = OrderedMap
{ omMap :: Map.Map k a
, omOrder :: [k]
}
} deriving (Eq)
instance (Ord k, Show k, Show a) => Show (OrderedMap k a) where
show m = "fromList " ++ show (toList m)
instance Functor (OrderedMap k) where
fmap = Forest.OrderedMap.map
instance (Ord k) => Foldable (OrderedMap k) where
foldMap f = foldMap f . elems
-- Invariants of this data type:
--
-- 1. The 'omOrder' list contains each key from 'omMap' exactly once.

View file

@ -1,11 +1,16 @@
{-# LANGUAGE OverloadedStrings #-}
module Forest.Util
( findPrev
(
-- * List operations
findPrev
, findNext
-- * Monadic looping constructs
, whileM
, runUntilJustM
, whileNothingM
-- * Multithreading helpers
, withThread
-- * Websocket helper functions
, sendPacket
, closeWithErrorMessage
, receivePacket
@ -24,9 +29,6 @@ findPrev f as = fst <$> find (f . snd) (zip as $ tail as)
findNext :: (a -> Bool) -> [a] -> Maybe a
findNext f as = snd <$> find (f . fst) (zip as $ tail as)
withThread :: IO () -> IO () -> IO ()
withThread thread main = withAsync thread $ const main
-- | Run a monadic action until it returns @False@ for the first time.
whileM :: Monad m => m Bool -> m ()
whileM f = do
@ -36,13 +38,16 @@ whileM f = do
else pure ()
-- | Run a monadic action until it returns @Just a@ for the first time.
runUntilJustM :: Monad m => m (Maybe a) -> m a
runUntilJustM f = do
whileNothingM :: Monad m => m (Maybe a) -> m a
whileNothingM f = do
result <- f
case result of
Nothing -> runUntilJustM f
Nothing -> whileNothingM f
Just a -> pure a
withThread :: IO () -> IO () -> IO ()
withThread thread main = withAsync thread $ const main
sendPacket :: ToJSON a => WS.Connection -> a -> IO ()
sendPacket conn packet = WS.sendTextData conn $ encode packet

1
forest-server/README.md Normal file
View file

@ -0,0 +1 @@
# forest-server

View file

@ -0,0 +1,45 @@
cabal-version: 1.12
-- This file has been generated from package.yaml by hpack version 0.31.2.
--
-- see: https://github.com/sol/hpack
--
-- hash: c0d366de2ff27f13dd69d751b47017143df332454ad700dd8fb5089d9837f1a8
name: forest-server
version: 0.1.0.0
synopsis: A framework for forest servers
description: Please see the README at <https://github.com/Garmelon/forest#readme>
homepage: https://github.com/Garmelon/forest#readme
bug-reports: https://github.com/Garmelon/forest/issues
author: Garmelon <joscha@plugh.de>
maintainer: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
license: MIT
build-type: Simple
extra-source-files:
README.md
source-repository head
type: git
location: https://github.com/Garmelon/forest
library
exposed-modules:
Forest.Server.Branch.SharedEdit
Forest.Server.Schema
Forest.Server.TreeApp
other-modules:
Paths_forest_server
hs-source-dirs:
src
build-depends:
base >=4.7 && <5
, containers
, forest-common
, microlens
, stm
, text
, transformers
, websockets
default-language: Haskell2010

View file

@ -0,0 +1,25 @@
name: forest-server
version: 0.1.0.0
license: MIT
author: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
synopsis: A framework for forest servers
description: Please see the README at <https://github.com/Garmelon/forest#readme>
github: Garmelon/forest
extra-source-files:
- README.md
dependencies:
- base >= 4.7 && < 5
- containers
- forest-common
- microlens
- stm
- text
- transformers
- websockets
library:
source-dirs: src

View file

@ -0,0 +1,80 @@
module Forest.Server.Branch.SharedEdit
( SharedEditGlobal
, sharedEditGlobal
, SharedEditLocal
, sharedEditLocal
, sharedEditDraw
, sharedEditUpdate
, sharedEditHandleEvent
, sharedEditBranch
) where
import Control.Concurrent
import Control.Monad
import qualified Data.Text as T
import Forest.Node
import Forest.Server.Schema
import Forest.Server.TreeApp
data SharedEditGlobal = SharedEditGlobal
{ seOnUpdate :: IO ()
, seNodeVar :: MVar Node
}
sharedEditGlobal :: IO () -> T.Text -> IO SharedEditGlobal
sharedEditGlobal onUpdate initialText = do
nodeVar <- newMVar $ txtNode "r" initialText
pure SharedEditGlobal
{ seOnUpdate = onUpdate
, seNodeVar = nodeVar
}
data SharedEditLocal = SharedEditLocal
{ seGlobal :: SharedEditGlobal
, seNode :: Node
}
sharedEditLocal :: SharedEditGlobal -> IO SharedEditLocal
sharedEditLocal seg = do
node <- readMVar $ seNodeVar seg
pure SharedEditLocal
{ seGlobal = seg
, seNode = node
}
sharedEditDraw :: SharedEditLocal -> Node
sharedEditDraw = seNode
sharedEditUpdate :: SharedEditLocal -> IO SharedEditLocal
sharedEditUpdate sel = do
node <- readMVar $ seNodeVar $ seGlobal sel
pure sel{seNode = node}
updateNode :: SharedEditLocal -> (Node -> Node) -> IO SharedEditLocal
updateNode sel f = do
let seg = seGlobal sel
nodeVar = seNodeVar seg
node <- takeMVar nodeVar
let node' = f node
putMVar nodeVar node'
when (node /= node') $ seOnUpdate seg
pure sel{seNode = node'}
sharedEditHandleEvent :: SharedEditLocal -> Path -> Event e -> IO SharedEditLocal
-- Ignore edits to the top node since it's only reply-able, not edit-able
sharedEditHandleEvent sel (Path []) (Edit _ _) = pure sel
sharedEditHandleEvent sel (Path []) (Delete _) = pure sel
sharedEditHandleEvent sel path (Edit _ text) =
updateNode sel $ adjustAt (\n -> n {nodeText = text}) path
sharedEditHandleEvent sel path (Delete _) =
updateNode sel $ deleteAt path
sharedEditHandleEvent sel path (Reply _ text) =
updateNode sel $ appendAt (txtNode "edr" text) path
sharedEditHandleEvent sel _ _ = pure sel
sharedEditBranch :: SharedEditLocal -> Branch SharedEditLocal e
sharedEditBranch sel = Branch
{ branchNode = sharedEditDraw sel
, branchHandleEvent = sharedEditHandleEvent sel
}

View file

@ -0,0 +1,85 @@
{-# LANGUAGE RankNTypes #-}
module Forest.Server.Schema
( Schema
, fork
, fork'
, leaf
, collect
, collectWith
, dispatch
-- * Useful type
, Branch(..)
, schemaDraw
, schemaHandleEvent
, schemaLift
) where
import qualified Data.Text as T
import Lens.Micro
import Forest.Node
import qualified Forest.OrderedMap as OMap
import Forest.Server.TreeApp
data Schema a
= Fork T.Text (OMap.OrderedMap NodeId (Schema a))
| Leaf a
instance Functor Schema where
fmap f (Leaf a) = Leaf $ f a
fmap f (Fork text children) = Fork text $ fmap (fmap f) children
fork :: T.Text -> [(NodeId, Schema a)] -> Schema a
fork text = Fork text . OMap.fromList
fork' :: T.Text -> [Schema a] -> Schema a
fork' text = fork text . zip keys
where
keys :: [NodeId]
keys = map (T.pack . show) [0::Int ..]
leaf :: a -> Schema a
leaf = Leaf
collect :: Schema Node -> Node
collect (Leaf node) = node
collect (Fork text children) = Node text mempty $ OMap.map collect children
collectWith :: (a -> Node) -> Schema a -> Node
collectWith f = collect . fmap f
dispatch :: Path -> Schema a -> Maybe (Path, a)
dispatch path (Leaf a) = Just (path, a)
dispatch (Path (x:xs)) (Fork _ omap) = dispatch (Path xs) =<< (omap OMap.!? x)
dispatch (Path []) (Fork _ _ ) = Nothing -- More specfic than required
data Branch s e = Branch
{ branchNode :: Node
, branchHandleEvent :: Path -> Event e -> IO s
}
schemaDraw :: Schema (Branch s e) -> Node
schemaDraw = collectWith branchNode
schemaHandleEvent :: Schema (Branch s e) -> Event e -> Maybe (IO s)
schemaHandleEvent schema event = do
path <- getPath event
(relPath, branch) <- dispatch path schema
pure $ branchHandleEvent branch relPath event
where
getPath (Edit path _) = Just path
getPath (Delete path) = Just path
getPath (Reply path _) = Just path
getPath (Act path) = Just path
getPath _ = Nothing
schemaLift :: Lens' s t -> (t -> Branch t e) -> s -> Branch s e
schemaLift l f s = Branch
{ branchNode = branchNode branch
, branchHandleEvent = \path event -> do
t' <- branchHandleEvent branch path event
pure $ s & l .~ t'
}
where
branch = f $ s ^. l

View file

@ -0,0 +1,113 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
-- | This module specifies a structure for forest server applications. It is
-- based on the way Brick models applications.
module Forest.Server.TreeApp
( Next
, continue
, halt
, Event(..)
, TreeApp(..)
, simpleConstructor
, runTreeApp
) where
import Control.Concurrent.STM
import Control.Monad
import Data.Function
import qualified Data.Text as T
import qualified Network.WebSockets as WS
import Forest.Api
import Forest.Node
import Forest.Util
data Next a = Continue a | Halt
continue :: a -> Next a
continue = Continue
halt :: Next a
halt = Halt
data Event e
= Edit Path T.Text
| Delete Path
| Reply Path T.Text
| Act Path
| Custom e
data TreeApp s e = TreeApp
{ appDraw :: s -> Node
, appHandleEvent :: s -> Event e -> IO (Next s)
, appConstructor :: forall a. (s -> Maybe (TChan e) -> IO a) -> IO a
}
simpleConstructor :: s -> (s -> IO a) -> IO a
simpleConstructor = (&)
{- The websocket app receiving and sending the packets -}
packetToEvent :: ClientPacket -> Maybe (Event e)
packetToEvent (ClientEdit path text) = Just $ Edit path text
packetToEvent (ClientDelete path) = Just $ Delete path
packetToEvent (ClientReply path text) = Just $ Reply path text
packetToEvent (ClientAct path) = Just $ Act path
packetToEvent (ClientHello _) = Nothing
receiveThread :: WS.Connection -> TChan (Event e) -> IO ()
receiveThread conn chan = forever $ do
packet <- receivePacket conn
case packetToEvent packet of
-- We can wrap a 'forever' around all of this because closeWithErrorMessage
-- throws a runtime exception once the connection is closed.
Nothing -> closeWithErrorMessage conn "Invalid packet: Hello"
Just event -> atomically $ writeTChan chan event
data RunState s e = RunState
{ rsEventChan :: TChan (Event e)
, rsCustomEventChan :: Maybe (TChan e)
, rsState :: s
, rsNode :: Node
}
readEvent :: RunState s e -> STM (Event e)
readEvent rs = case rsCustomEventChan rs of
Nothing -> readTChan ec
Just cec -> readTChan ec `orElse` (Custom <$> readTChan cec)
where
ec = rsEventChan rs
sendNodeUpdate :: WS.Connection -> Node -> Node -> IO ()
sendNodeUpdate conn nodeOld nodeNew = case diffNodes nodeOld nodeNew of
Nothing -> putStrLn "Sending no update because the node didn't change"
Just (path, updatedNode) -> do
putStrLn $ "Sending partial update at " ++ show path ++ ": " ++ show updatedNode
sendPacket conn $ ServerUpdate path updatedNode
runUntilHalt :: WS.Connection -> TreeApp s e -> RunState s e -> IO ()
runUntilHalt conn app rs = do
event <- atomically $ readEvent rs
next <- appHandleEvent app (rsState rs) event
case next of
Halt -> pure ()
Continue state' -> do
let node' = appDraw app state'
sendNodeUpdate conn (rsNode rs) node'
runUntilHalt conn app rs{rsState = state', rsNode = node'}
runTreeApp :: Int -> TreeApp s e -> WS.ServerApp
runTreeApp pingDelay app pendingConn = do
conn <- WS.acceptRequest pendingConn
chan <- atomically newTChan
WS.withPingThread conn pingDelay (pure ()) $
appConstructor app $ \initialState customChan -> do
firstPacket <- receivePacket conn
case firstPacket of
ClientHello _ -> do
let initialNode = appDraw app initialState
rs = RunState chan customChan initialState initialNode
sendPacket conn $ ServerHello [] initialNode
withThread (receiveThread conn chan) $ runUntilHalt conn app rs
_ -> closeWithErrorMessage conn "Invalid packet: Expected hello"

1
forest-tui/README.md Normal file
View file

@ -0,0 +1 @@
# forest-tui

View file

@ -0,0 +1,74 @@
cabal-version: 1.12
-- This file has been generated from package.yaml by hpack version 0.31.2.
--
-- see: https://github.com/sol/hpack
--
-- hash: 9ca3a1fe555e2dceb3459b6ae920b1ed93aac76398d4909a7030d7992b79ce40
name: forest-tui
version: 0.1.0.0
synopsis: A terminal-based client for forest
description: Please see the README at <https://github.com/Garmelon/forest#readme>
homepage: https://github.com/Garmelon/forest#readme
bug-reports: https://github.com/Garmelon/forest/issues
author: Garmelon <joscha@plugh.de>
maintainer: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
license: MIT
build-type: Simple
extra-source-files:
README.md
source-repository head
type: git
location: https://github.com/Garmelon/forest
library
exposed-modules:
Forest.Client
Forest.Client.NodeUtil
Forest.Client.Options
Forest.Client.UiState
Forest.Client.Websocket
Forest.Client.Widgets.NodeEditor
Forest.Client.Widgets.WidgetTree
other-modules:
Paths_forest_tui
hs-source-dirs:
src
build-depends:
base >=4.7 && <5
, brick
, containers
, forest-common
, optparse-applicative
, safe
, text
, text-zipper
, vty
, websockets
, wuss
default-language: Haskell2010
executable forest
main-is: Main.hs
other-modules:
Paths_forest_tui
hs-source-dirs:
app
ghc-options: -threaded -rtsopts -with-rtsopts=-N
build-depends:
base >=4.7 && <5
, brick
, containers
, forest-common
, forest-tui
, optparse-applicative
, safe
, text
, text-zipper
, vty
, websockets
, wuss
default-language: Haskell2010

39
forest-tui/package.yaml Normal file
View file

@ -0,0 +1,39 @@
name: forest-tui
version: 0.1.0.0
license: MIT
author: Garmelon <joscha@plugh.de>
copyright: 2020 Garmelon
synopsis: A terminal-based client for forest
description: Please see the README at <https://github.com/Garmelon/forest#readme>
github: Garmelon/forest
extra-source-files:
- README.md
dependencies:
- base >= 4.7 && < 5
- brick
- containers
- forest-common
- optparse-applicative
- safe
- text
- text-zipper
- vty
- websockets
- wuss
library:
source-dirs: src
executables:
forest:
source-dirs: app
main: Main.hs
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- forest-tui

View file

@ -8,15 +8,16 @@ module Forest.Client
import Brick
import Brick.BChan
import Brick.Widgets.Edit
import Control.Monad
import Control.Monad.IO.Class
import qualified Graphics.Vty as Vty
import qualified Network.WebSockets as WS
import qualified Graphics.Vty as Vty
import qualified Network.WebSockets as WS
import Forest.Api
import Forest.Client.UiState
import Forest.Client.Websocket
import Forest.Client.WidgetTree
import Forest.Client.Widgets.WidgetTree
import Forest.Node
import Forest.Util
@ -54,33 +55,39 @@ onUiState' cs f = do
{- ... without active editor -}
deleteNode :: ClientState -> ClientM ()
deleteNode cs =
when (flagDelete $ nodeFlags $ focusedNode s) $
liftIO $ sendPacket (csConn cs) $ ClientDelete (focusedPath s)
where
s = csUiState cs
actUponNode :: ClientState -> ClientM ()
actUponNode cs =
when (flagAct $ nodeFlags $ focusedNode s) $
liftIO $ sendPacket (csConn cs) $ ClientAct (focusedPath s)
where
s = csUiState cs
onKeyWithoutEditor :: ClientState -> Vty.Event -> ClientM (Next ClientState)
onKeyWithoutEditor cs (Vty.EvKey k _)
| k `elem` quitKeys = halt cs
| k `elem` foldKeys = onUiState cs toggleFoldAtFocus
| k `elem` upKeys = onUiState cs moveFocusUp
| k `elem` downKeys = onUiState cs moveFocusDown
| k `elem` editKeys = onUiState cs editCurrentNode
| k `elem` deleteKeys = do
when (flagDelete $ nodeFlags $ getFocusedNode $ csUiState cs) $
liftIO $ sendPacket (csConn cs) $ ClientDelete (getFocusedPath $ csUiState cs)
continue cs
| k `elem` replyKeys = onUiState cs (replyToCurrentNode . unfoldAtFocus)
| k `elem` replyKeys' = onUiState cs replyAfterCurrentNode
| k `elem` actKeys = do
when (flagAct $ nodeFlags $ getFocusedNode $ csUiState cs) $
liftIO $ sendPacket (csConn cs) $ ClientAct (getFocusedPath $ csUiState cs)
continue cs
where
quitKeys = [Vty.KChar 'q', Vty.KEsc]
foldKeys = [Vty.KChar '\t']
upKeys = [Vty.KChar 'k', Vty.KUp]
downKeys = [Vty.KChar 'j', Vty.KDown]
editKeys = [Vty.KChar 'e']
deleteKeys = [Vty.KChar 'd']
replyKeys = [Vty.KChar 'r']
replyKeys' = [Vty.KChar 'R']
actKeys = [Vty.KChar 'a', Vty.KChar ' ', Vty.KEnter]
| k `elem` [Vty.KChar 'q', Vty.KEsc] = halt cs
| k == Vty.KChar '\t' = onUiState cs toggleFoldAtFocus
| k `elem` [Vty.KChar 'k', Vty.KUp] = onUiState cs moveFocusUp
| k `elem` [Vty.KChar 'j', Vty.KDown] = onUiState cs moveFocusDown
| k `elem` [Vty.KChar 'K', Vty.KPageUp] = onUiState cs moveFocusToPrevSibling
| k `elem` [Vty.KChar 'J', Vty.KPageDown] =
onUiState cs moveFocusToNextSibling
| k `elem` [Vty.KChar 'h', Vty.KLeft] = onUiState cs moveFocusToParent
| k `elem` [Vty.KChar 'g', Vty.KHome] = onUiState cs moveFocusToTop
| k `elem` [Vty.KChar 'G', Vty.KEnd] = onUiState cs moveFocusToBottom
| k == Vty.KChar 'e' = onUiState cs editCurrentNode
| k == Vty.KChar 'r' = onUiState cs (replyToCurrentNode . unfoldAtFocus)
| k == Vty.KChar 'R' = onUiState cs replyAfterCurrentNode
| k `elem` [Vty.KChar 'd', Vty.KChar 'x', Vty.KDel, Vty.KBS] =
deleteNode cs *> continue cs
| k `elem` [Vty.KChar 'a', Vty.KChar ' ', Vty.KEnter] =
actUponNode cs *> continue cs
onKeyWithoutEditor cs _ = continue cs
{- ... with active editor -}
@ -118,7 +125,7 @@ clientHandleEvent cs (VtyEvent ev)
| isEditorActive (csUiState cs) = onKeyWithEditor cs ev
| otherwise = onKeyWithoutEditor cs ev
clientHandleEvent cs (AppEvent ev) = case ev of
EventNode node -> onUiState cs $ replaceRootNode node
EventNode node -> onUiState cs $ replaceRootNode node
EventConnectionClosed -> halt cs
clientHandleEvent cs _ = continue cs
@ -128,15 +135,16 @@ clientAttrMap = attrMap Vty.defAttr
, ("focus", Vty.defAttr `Vty.withBackColor` Vty.blue)
, ("flags", Vty.defAttr `Vty.withForeColor` Vty.brightBlack)
, (treeLineAttr, Vty.defAttr `Vty.withForeColor` Vty.brightBlack)
, (editAttr, Vty.defAttr `Vty.withBackColor` Vty.brightBlack)
]
clientApp :: App ClientState Event ResourceName
clientApp = App
{ appDraw = clientDraw
{ appDraw = clientDraw
, appChooseCursor = showFirstCursor
, appHandleEvent = clientHandleEvent
, appStartEvent = pure
, appAttrMap = const clientAttrMap
, appHandleEvent = clientHandleEvent
, appStartEvent = pure
, appAttrMap = const clientAttrMap
}
runClient :: WS.Connection -> BChan Event -> Node -> IO ()

View file

@ -2,7 +2,6 @@ module Forest.Client.NodeUtil
( Unfolded
, foldVisibleNodes
, applyFolds
, flatten
, findPrevNode
, findNextNode
) where
@ -41,14 +40,6 @@ applyFolds unfolded node
OMap.mapWithKey (\nid -> applyFolds $ narrowSet nid unfolded) $
nodeChildren node
-- | Return the 'Path's to a node and its subnodes in the order they would be
-- displayed in.
flatten :: Node -> [Path]
flatten node =
let flattenedChildren =
mapChildren (\nid n -> map (Path [nid] <>) $ flatten n) node
in Path [] : concat flattenedChildren
findPrevNode :: Node -> Path -> Path
findPrevNode node path = fromMaybe path $ findPrev (==path) $ flatten node

View file

@ -3,16 +3,17 @@
module Forest.Client.UiState
( UiState
, newUiState
, getFocusedPath
, getFocusedNode
, focusedPath
, focusedNode
-- * Modifying the UI state
, replaceRootNode
, moveFocusUp
, moveFocusDown
, moveFocusToFirstChild
, moveFocusToLastChild
, moveFocusToFirstSibling
, moveFocusToLastSibling
, moveFocusToParent
, moveFocusToPrevSibling
, moveFocusToNextSibling
, moveFocusToTop
, moveFocusToBottom
, foldAtFocus
, unfoldAtFocus
, toggleFoldAtFocus
@ -33,17 +34,18 @@ module Forest.Client.UiState
) where
import Brick
import Control.Monad
import Data.List
import Data.Maybe
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Graphics.Vty as Vty
import Safe
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Graphics.Vty as Vty
import Forest.Client.NodeEditor
import Forest.Client.NodeUtil
import Forest.Client.WidgetTree
import Forest.Client.Widgets.NodeEditor
import Forest.Client.Widgets.WidgetTree
import Forest.Node
import qualified Forest.OrderedMap as OMap
import qualified Forest.OrderedMap as OMap
data EditorInfo n = EditorInfo
{ eiEditor :: !(NodeEditor n)
@ -68,14 +70,17 @@ newUiState editorName node = UiState
, uiEditorName = editorName
}
getFocusedPath :: UiState n -> Path
getFocusedPath = uiFocused
focusedPath :: UiState n -> Path
focusedPath = uiFocused
getFocusedNode :: UiState n -> Node
getFocusedNode s = fromMaybe rootNode $ applyPath (uiFocused s) rootNode
focusedNode :: UiState n -> Node
focusedNode s = fromMaybe rootNode $ applyPath rootNode $ uiFocused s
where
rootNode = uiRootNode s
foldedRootNode :: UiState n -> Node
foldedRootNode s = applyFolds (uiUnfolded s) (uiRootNode s)
{- Modifying -}
-- | Only keep those unfolded nodes that actually exist.
@ -86,77 +91,88 @@ validateUnfolded s =
-- | Try to find the closest parent to a 'Path' that exists in the 'Node'.
findValidParent :: Node -> Path -> Path
findValidParent _ (Path []) = Path []
findValidParent node (Path (x:xs)) = case applyId x node of
findValidParent node (Path (x:xs)) = case applyId node x of
Nothing -> Path []
Just child -> Path [x] <> findValidParent child (Path xs)
-- | Modify the focused path so it always points to an existing node.
-- | Move to the closest valid parent as a last-ditch effort if the current
-- focus path is invalid.
validateFocused :: UiState n -> UiState n
validateFocused s =
let foldedRootNode = applyFolds (uiUnfolded s) (uiRootNode s)
in s {uiFocused = findValidParent foldedRootNode $ uiFocused s}
s {uiFocused = findValidParent (foldedRootNode s) (uiFocused s)}
-- | Close the editor if it doesn't point to a valid path.
validateEditor :: UiState n -> UiState n
validateEditor s = case uiEditor s of
Nothing -> s
Just e -> keepEditor $ fromMaybe False $ do
node <- applyPath (eiPath e) (uiRootNode s)
let flags = nodeFlags node
pure $ if eiReply e then flagReply flags else flagEdit flags
where
keepEditor True = s
keepEditor False = s {uiEditor = Nothing}
validateEditor s = fromMaybe s{uiEditor = Nothing} $ do
e <- uiEditor s
node <- applyPath (uiRootNode s) (eiPath e)
let flags = nodeFlags node
guard $ if eiReply e then flagReply flags else flagEdit flags
pure s
-- | Modify the UI state so it is consistent again.
validate :: UiState n -> UiState n
validate = validateEditor . validateFocused . validateUnfolded
replaceRootNode :: Node -> UiState n -> UiState n
replaceRootNode node s = validate s {uiRootNode = node}
-- | Find a node that is close to the previously focused node, taking into
-- account its previous position in the tree.
findNextValidNode :: Node -> Node -> Path -> Path
findNextValidNode _ _ (Path []) = Path []
findNextValidNode from to (Path (x:xs)) = fromMaybe (Path []) $ do
fromNode <- applyId from x
case applyId to x of
Just toNode -> pure $ Path [x] <> findNextValidNode fromNode toNode (Path xs)
Nothing -> do
fromIdx <- elemIndex x $ OMap.keys $ nodeChildren from
let toKeys = OMap.keys $ nodeChildren to
x' <- getValueClosestToIndex fromIdx toKeys
pure $ Path [x']
where
-- Slightly unsafe code, but it should be fine
getValueClosestToIndex idx list
| length list > idx = Just $ list !! idx
| null list = Nothing
| otherwise = Just $ last list
moveFocus :: (Node -> Path -> Path) -> UiState n -> UiState n
moveFocus f s =
let foldedRootNode = applyFolds (uiUnfolded s) (uiRootNode s)
in validateFocused s {uiFocused = f foldedRootNode $ uiFocused s}
replaceRootNode :: Node -> UiState n -> UiState n
replaceRootNode node s = validate s
{ uiRootNode = node
, uiFocused = findNextValidNode (uiRootNode s) node (uiFocused s)
}
moveFocus :: (Node -> Path -> Maybe Path) -> UiState n -> UiState n
moveFocus f s = fromMaybe s $ do
newFocus <- f (foldedRootNode s) (uiFocused s)
pure $ validateFocused s{uiFocused = newFocus}
moveFocusUp :: UiState n -> UiState n
moveFocusUp = moveFocus findPrevNode
moveFocusUp = moveFocus prevNode
moveFocusDown :: UiState n -> UiState n
moveFocusDown = moveFocus findNextNode
moveFocusDown = moveFocus nextNode
moveFocusToPrevSibling :: UiState n -> UiState n
moveFocusToPrevSibling = moveFocus prevSibling
moveFocusToNextSibling :: UiState n -> UiState n
moveFocusToNextSibling = moveFocus nextSibling
moveFocusToParent :: UiState n -> UiState n
moveFocusToParent = moveFocus $ \_ focused -> fromMaybe focused $ parent focused
moveFocusToParent = moveFocus $ const parent
moveFocusToChild :: ([NodeId] -> Maybe NodeId) -> UiState n -> UiState n
moveFocusToChild f = moveFocus $ \node focused -> fromMaybe focused $ do
siblings <- nodeChildren <$> applyPath focused node
firstSiblingName <- f $ OMap.keys siblings
pure $ focused <> Path [firstSiblingName]
moveFocusToTop :: UiState n -> UiState n
moveFocusToTop = moveFocus firstNode
moveFocusToFirstChild :: UiState n -> UiState n
moveFocusToFirstChild = moveFocusToChild headMay
moveFocusToLastChild :: UiState n -> UiState n
moveFocusToLastChild = moveFocusToChild lastMay
moveFocusToSibling :: ([NodeId] -> Maybe NodeId) -> UiState n -> UiState n
moveFocusToSibling f s
| uiFocused s == mempty = s
| otherwise = moveFocusToChild f $ moveFocusToParent s
moveFocusToFirstSibling :: UiState n -> UiState n
moveFocusToFirstSibling = moveFocusToSibling headMay
moveFocusToLastSibling :: UiState n -> UiState n
moveFocusToLastSibling = moveFocusToSibling lastMay
moveFocusToBottom :: UiState n -> UiState n
moveFocusToBottom = moveFocus lastNode
foldAtFocus :: UiState n -> UiState n
foldAtFocus s = validateUnfolded $ s {uiUnfolded = Set.delete (uiFocused s) (uiUnfolded s)}
foldAtFocus s =
validateUnfolded s{uiUnfolded = Set.delete (uiFocused s) (uiUnfolded s)}
unfoldAtFocus :: UiState n -> UiState n
unfoldAtFocus s = validateUnfolded $ s {uiUnfolded = Set.insert (uiFocused s) (uiUnfolded s)}
unfoldAtFocus s =
validateUnfolded s{uiUnfolded = Set.insert (uiFocused s) (uiUnfolded s)}
toggleFoldAtFocus :: UiState n -> UiState n
toggleFoldAtFocus s = if uiFocused s `Set.member` uiUnfolded s
@ -165,13 +181,13 @@ toggleFoldAtFocus s = if uiFocused s `Set.member` uiUnfolded s
editNode :: Bool -> Path -> UiState n -> UiState n
editNode reply path s =
let text = if reply then "" else nodeText $ getFocusedNode s
let text = if reply then "" else nodeText $ focusedNode s
editorInfo = EditorInfo
{ eiEditor = beginEdit (uiEditorName s) text
, eiPath = path
, eiReply = reply
}
in validateEditor $ s {uiEditor = Just editorInfo}
in validateEditor s{uiEditor = Just editorInfo}
-- | Begin editing the currently focused node. Discards any current editor
-- status.
@ -180,20 +196,18 @@ editCurrentNode s = editNode False (uiFocused s) s
-- | Reply to the currently focused node. Discards any current editor status.
replyToCurrentNode :: UiState n -> UiState n
replyToCurrentNode s = editNode True (uiFocused s) $ moveFocusToLastChild s
replyToCurrentNode s = editNode True (uiFocused s) s
-- | Reply in parallel to the currently focused node, unless it is the root node
-- (in which case no action is taken).
replyAfterCurrentNode :: UiState n -> UiState n
replyAfterCurrentNode s = case parent $ uiFocused s of
Nothing -> s
Just path -> editNode True path $ moveFocusToLastSibling s
Just path -> editNode True path s
isEditorActive :: UiState n -> Bool
isEditorActive = isJust . uiEditor
-- | Return an action to update the editor if the editor is currently active.
-- Returns 'Nothing' otherwise.
updateEditor :: Vty.Event -> UiState n -> EventM n (UiState n)
updateEditor ev s = case uiEditor s of
Nothing -> pure s
@ -208,28 +222,27 @@ data EditResult = EditResult
} deriving (Show)
finishEditing :: UiState n -> (UiState n, Maybe EditResult)
finishEditing s = case uiEditor s of
Nothing -> (s, Nothing)
Just e ->
let editResult = EditResult
{ erText = getCurrentText $ eiEditor e
, erPath = eiPath e
, erReply = eiReply e
}
in (abortEditing s, Just editResult)
finishEditing s = fromMaybe (s, Nothing) $ do
e <- uiEditor s
let editResult = EditResult
{ erText = getCurrentText $ eiEditor e
, erPath = eiPath e
, erReply = eiReply e
}
pure (abortEditing s, Just editResult)
abortEditing :: UiState n -> UiState n
abortEditing s = s {uiEditor = Nothing}
abortEditing s = s{uiEditor = Nothing}
{- Rendering -}
decorateExpand :: Bool -> Widget n -> Widget n
decorateExpand True widget = withDefAttr "expand" widget
decorateExpand False widget = withDefAttr "noexpand" widget
decorateExpand True = withDefAttr "expand"
decorateExpand False = id
decorateFocus :: Bool -> Widget n -> Widget n
decorateFocus True widget = visible $ withDefAttr "focus" widget
decorateFocus False widget = withDefAttr "nofocus" widget
decorateFocus True = withDefAttr "focus"
decorateFocus False = id
decorateFlags :: NodeFlags -> Widget n -> Widget n
decorateFlags node widget =
@ -244,23 +257,33 @@ renderNode :: Bool -> Node -> Widget n
renderNode focused node =
decorateFlags (nodeFlags node) $
decorateFocus focused $
decorateExpand (not $ OMap.null $ nodeChildren node) $
txtWrap $ nodeText node
nodeToTree :: (Ord n, Show n) => UiState n -> Path -> Node -> Maybe [WidgetTree n] -> WidgetTree n
nodeToTree s path node maybeChildren = case uiEditor s of
Nothing ->
let isFocused = path == uiFocused s
in WidgetTree (renderNode isFocused node) children
Just e ->
let renderedEditor = renderNodeEditor $ eiEditor e
renderedEditorTree = WidgetTree renderedEditor []
in if path /= eiPath e
then WidgetTree (renderNode False node) children
else if eiReply e
then WidgetTree (renderNode False node) $ children ++ [renderedEditorTree]
else WidgetTree renderedEditor children
decorateExpand (hasChildren node) $
padRight Max text
where
-- The height of the text widget must be at least 1 for 'padRight Max' to
-- expand it. As far as I know, if the text has at least one character, it
-- also has a height of at least 1, but if it has no characters, its height
-- is 0. Because of that, we insert a filler space if the text is empty.
text
| T.null $ nodeText node = txt " "
| otherwise = txtWrap $ nodeText node
nodeToTree
:: (Ord n, Show n)
=> UiState n
-> Path
-> Node
-> Maybe [WidgetTree n]
-> WidgetTree n
nodeToTree s path node maybeChildren = case uiEditor s of
Just e | path == eiPath e ->
let renderedEditor = renderNodeEditor $ eiEditor e
in if eiReply e
then WidgetTree renderedNode $ children ++ [WidgetTree renderedEditor []]
else WidgetTree renderedEditor children
_ -> WidgetTree (visible renderedNode) children
where
renderedNode = renderNode (path == uiFocused s) node
children = fromMaybe [] maybeChildren
renderUiState :: (Ord n, Show n) => IndentOptions -> UiState n -> Widget n

View file

@ -1,6 +1,6 @@
{-# LANGUAGE OverloadedStrings #-}
module Forest.Client.NodeEditor
module Forest.Client.Widgets.NodeEditor
( NodeEditor
, getCurrentText
, beginEdit
@ -13,7 +13,6 @@ import Brick.Widgets.Edit
import qualified Data.Text as T
import Data.Text.Zipper
import qualified Graphics.Vty as Vty
import Lens.Micro
newtype NodeEditor n = NodeEditor (Editor T.Text n)
deriving (Show)
@ -43,5 +42,5 @@ renderNodeEditor ne@(NodeEditor e) =
makeVisible $ vLimit height $ renderEditor renderLines True e
where
height = length $ getCurrentLines ne
(row, col) = cursorPosition $ e ^. editContentsL
(row, col) = cursorPosition $ editContents e
makeVisible = visibleRegion (Location (col, row)) (1, 1)

View file

@ -0,0 +1,115 @@
{-# LANGUAGE OverloadedStrings #-}
module Forest.Client.Widgets.WidgetTree
( WidgetTree(..)
, renderWidgetTreeWith
, renderWidgetTree
, treeLineAttr
, IndentOptions(..)
, boxDrawingBranching
, boxDrawingLine
, asciiBranching
, asciiLine
) where
import Brick
import qualified Data.Text as T
import qualified Graphics.Vty as Vty
data WidgetTree n = WidgetTree (Widget n) [WidgetTree n]
indentWith :: AttrName -> T.Text -> T.Text -> Widget n -> Widget n
-- The "left" variables are for rendering the indentation text, the "right"
-- variables are for the rendered wrapped widget.
indentWith indentAttrName firstLine otherLines wrapped =
Widget (hSize wrapped) (vSize wrapped) $ do
let leftWidth = max (T.length firstLine) (T.length otherLines)
context <- getContext
rightResult <- render $ hLimit (availWidth context - leftWidth) wrapped
let rightImage = image rightResult
-- Construct the Vty image containing the indentation text
height = Vty.imageHeight rightImage
leftLines = firstLine : replicate (height - 1) otherLines
leftAttribute = attrMapLookup indentAttrName $ ctxAttrMap context
leftImage = Vty.vertCat $ map (Vty.text' leftAttribute) leftLines
-- Add the indentation text to the left of the result image
combinedImage = leftImage Vty.<|> image rightResult
offset = Location (leftWidth, 0)
result = (addResultOffset offset rightResult) {image=combinedImage}
pure result
indent :: AttrName -> IndentOptions -> [Widget n] -> Widget n
indent indentAttrName opts widgets = vBox $ reverse $ case reverse widgets of
[] -> []
(w:ws) ->
indentWith indentAttrName (indentLastNodeFirstLine opts) (indentLastNodeRest opts) w :
map (indentWith indentAttrName (indentNodeFirstLine opts) (indentNodeRest opts)) ws
renderWidgetTreeWith :: AttrName -> IndentOptions -> WidgetTree n -> Widget n
renderWidgetTreeWith indentAttrName opts (WidgetTree node children) =
node <=> indent indentAttrName opts (map (renderWidgetTreeWith indentAttrName opts) children)
renderWidgetTree :: IndentOptions -> WidgetTree n -> Widget n
renderWidgetTree = renderWidgetTreeWith treeLineAttr
-- | The attribute that 'renderWidgetTree' uses.
treeLineAttr :: AttrName
treeLineAttr = "treeLine"
-- | These options control how a tree is rendered.
--
-- In the following example, the indent options are set to @'IndentOptions' "a" "b" "c" "d"@:
--
-- > a This is the first node.
-- > b c It has a child.
-- > a This is a...
-- > b multiline...
-- > b node.
-- > c This is the last node.
-- > d c It has one child.
-- > d c And another one.
--
-- Warning: The options /must/ be single line strings and /must not/ contain
-- newlines of any sort.
data IndentOptions = IndentOptions
{ indentNodeFirstLine :: T.Text
-- ^ This is prepended to the first line of a node.
, indentNodeRest :: T.Text
-- ^ This is prepended to all other lines of a node, including its subnodes.
, indentLastNodeFirstLine :: T.Text
-- ^ This is prepended to the first line of the last node.
, indentLastNodeRest :: T.Text
-- ^ This is prepended to all other lines of the last node, including its subnodes.
} deriving (Show, Eq)
boxDrawingBranching :: IndentOptions
boxDrawingBranching = IndentOptions
{ indentNodeFirstLine = "├╴"
, indentNodeRest = ""
, indentLastNodeFirstLine = "└╴"
, indentLastNodeRest = " "
}
boxDrawingLine :: IndentOptions
boxDrawingLine = IndentOptions
{ indentNodeFirstLine = ""
, indentNodeRest = ""
, indentLastNodeFirstLine = ""
, indentLastNodeRest = ""
}
asciiBranching :: IndentOptions
asciiBranching = IndentOptions
{ indentNodeFirstLine = "+-"
, indentNodeRest = "| "
, indentLastNodeFirstLine = "+-"
, indentLastNodeRest = " "
}
asciiLine :: IndentOptions
asciiLine = IndentOptions
{ indentNodeFirstLine = "| "
, indentNodeRest = "| "
, indentLastNodeFirstLine = "| "
, indentLastNodeRest = "| "
}

67
forest-web/about.html Normal file
View file

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Forest - About</title>
<link rel="stylesheet" href="main.css">
</head>
<body>
<main>
<h1>Forest</h1>
<h2>Description</h2>
<p>
Forest is a project based around interacting with trees of
plain-text nodes. It has an API that is intentionally kept
simple. Writing your own clients or bots is explicitly
encouraged!
</p>
<p>
At the moment, there are a server and a terminal-based client
written in haskell, and the web-based client you're using right
now, made with (vanilla) javascript. The web-based client is
heavily based on the terminal-based client, both in look and
behaviour. The color scheme is just my terminal's current color
scheme.
</p>
<h2>Code and docs</h2>
<ol>
<li><a href="https://github.com/Garmelon/forest">Server and terminal-based client</a></li>
<li>Web-based client (coming soon)</li>
<li><a href="https://github.com/Garmelon/forest/blob/master/docs/API.md#api">API documentation</a></li>
</ol>
<h2>Usage</h2>
<h3>Controls</h3>
<pre>
tab - fold/unfold current node
arrow keys/jk - move cursor
</pre>
<h3>Permissions</h3>
<p>
A node's permissions are displayed at the right side of the
screen, like this:
<span style="color: var(--bright-black);">(edra)</span>.
If a permission is set, its character is displayed. Otherwise, a
dash is displayed in its place. Only when a permission is set
can its action be performed.
</p>
<pre>
e (edit) - edit a node's text
d (delete) - delete a node
r (reply) - reply to a node
a (act) - perform a node-specific action
</pre>
<h3>Colors</h3>
<p>
The cursor position is marked by a
<span style="background-color: var(--blue);">blue background</span>.
If a node is colored
<span style="color: var(--yellow); font-weight: bold;">yellow</span>,
it has child nodes.
</p>
</main>
</body>
</html>

39
forest-web/init.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Forest</title>
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="node.css">
<link rel="stylesheet" href="settings.css">
<script defer src="node.js"></script>
<script defer src="settings.js"></script>
</head>
<body>
<div id="root-node-container">
<div class="node">
<div class="node-line">
<span class="node-text">Please enable javascript.</span>
<span class="node-permissions">(----)</span>
</div>
<div class="node-children"></div>
</div>
</div>
<div id="settings">
<button>Settings</button>
<form>
<div>
<input type="checkbox" id="curvy-lines-checkbox">
<label for="curvy-lines-checkbox" title="Make the end bits of the tree lines curved">Curvy lines</label>
</div>
</form>
</div>
<div id="about">
<a href="about.html">About</a>
</div>
</body>
</html>

76
forest-web/main.css Normal file
View file

@ -0,0 +1,76 @@
html {
/* My terminal's color scheme */
--background: #000000;
--foreground: #babdb6;
--black: #2e3436;
--bright-black: #555753;
--red: #cc0000;
--bright-red: #ef2929;
--green: #4e9a06;
--bright-green: #8ae234;
--yellow: #c4a000;
--bright-yellow: #fce94f;
--blue: #3465a4;
--bright-blue: #729fcf;
--magenta: #75507b;
--bright-magenta: #ad7fa8;
--cyan: #06989a;
--bright-cyan: #34e2e2;
--white: #d3d7cf;
--bright-white: #eeeeec;
font-family: monospace;
font-size: 16px;
color: var(--foreground);
background-color: var(--background);
}
body {
max-width: 1024px;
margin: 0 auto;
padding: 2em;
}
h1, h2, h3, h4, h5, h6 {
color: var(--white);
margin-top: 1.5em;
}
h1 {
margin-top: 0;
font-size: 2em;
}
h2 {
text-decoration: underline;
}
a {
color: var(--bright-blue);
}
a:visited {
color: var(--bright-magenta);
}
/* Input elements */
input[type="checkbox"] {
display: none;
}
input[type="checkbox"] + label::before {
content: "[_] ";
font-weight: bold;
}
input[type="checkbox"]:checked + label::before {
content: "[X] ";
}
button, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
margin: 0;
padding: 0;
border: none;
outline: none;
}
button {
font-weight: bold;
}
textarea {
color: var(--foreground);
background-color: var(--bright-black);
}

71
forest-web/node.css Normal file
View file

@ -0,0 +1,71 @@
.node-line {
display: flex;
}
.node-text {
flex-grow: 1;
}
.node-permissions {
color: var(--bright-black);
margin-left: 1ch;
}
.node textarea {
width: 100%;
resize: none;
}
/* Special display states a node can be in */
.has-children > .node-line > .node-text {
font-weight: bold;
color: var(--yellow);
}
.has-cursor > .node-line > .node-text {
background-color: var(--blue);
}
.has-editor > .node-line {
display: none;
}
.is-folded > .node-children {
display: none;
}
/* Fancy tree lines */
.node-children > *, .node-children > *::before {
border-color: var(--bright-black);
border-width: 2px;
}
.node-children > * {
position: relative; /* .node is containing block for its .node::before */
margin-left: calc(0.5ch - 1px);
padding-left: calc(1.5ch - 1px);
border-left-style: solid;
}
.node-children > *:last-child {
padding-left: calc(1.5ch + 1px);
border-left-style: none;
}
.node-children > *::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: calc(1ch - 1px);
height: calc(0.6em - 1px);
border-bottom-style: solid;
}
.node-children > *:last-child::before {
border-left-style: solid;
transition: border-bottom-left-radius 0.4s;
}
/* Curvy lines */
.curvy .node-children > *:last-child, .curvy .node-children > *:last-child::before {
border-bottom-left-radius: 6px;
}
/* About link in bottom right corner */
#about {
position: fixed;
bottom: 0;
right: 0;
margin: 1ch;
}

684
forest-web/node.js Normal file
View file

@ -0,0 +1,684 @@
"use strict";
/*
* Utility functions
*/
function removeAllChildren(element) {
while (element.firstChild) {
element.removeChild(element.lastChild);
}
}
// Create a new DOM element.
// 'classes' can either be a string or a list of strings.
// A child can either be a string or a DOM element.
function newElement(type, classes, ...children) {
let e = document.createElement(type);
if (classes !== undefined) {
if (typeof classes == "string") {
e.classList.add(classes);
} else if (classes instanceof Array) {
e.classList.add(...classes);
}
}
children.forEach(child => {
if (typeof child == "string") {
e.appendChild(document.createTextNode(child));
} else {
e.appendChild(child);
}
});
return e;
}
/*
* Classes
*/
// Enum representing useful positions relative to a node.
const RelPos = Object.freeze({
FIRST_CHILD: 1,
NEXT_SIBLING: 2,
});
class Path {
constructor(...components) {
this._components = components.slice();
}
get components() {
return this._components.slice();
}
get length() {
return this._components.length;
}
get last() {
return this._components[this.length - 1];
}
get parent() {
if (this.length === 0) return undefined;
return new Path(...this._components.slice(0, this.length - 1));
}
append(nodeId) {
return new Path(...this._components.concat([nodeId]));
}
concat(otherPath) {
return new Path(...this._components.concat(otherPath._components));
}
}
class NodeElements {
constructor() {
this._elText = newElement("span", "node-text");
this._elPermissions = newElement("span", "node-permissions");
this._elChildren = newElement("div", "node-children");
let line = newElement("div", "node-line", this._elText, this._elPermissions);
this._elMain = newElement("div", ["node", "is-folded"], line, this._elChildren);
}
get text() {
return this._elText.textContent;
}
set text(text) {
this._elText.textContent = text;
}
set permissions(perms) {
this._elPermissions.textContent = perms.asText;
}
get hasChildren() {
return this._elMain.classList.contains("has-children");
}
set hasChildren(flag) {
return this._elMain.classList.toggle("has-children", flag);
}
removeAllChildren() {
removeAllChildren(this._elChildren);
}
addChild(child) {
this._elChildren.appendChild(child._elMain);
}
appendTo(element) {
element.appendChild(this._elMain);
}
get folded() {
return this._elMain.classList.contains("is-folded");
}
set folded(flag) {
this._elMain.classList.toggle("is-folded", flag);
}
toggleFolded() {
this.folded = !this.folded;
}
get hasCursor() {
return this._elMain.classList.contains("has-cursor");
}
set hasCursor(flag) {
return this._elMain.classList.toggle("has-cursor", flag);
}
get hasEditor() {
return this._elMain.classList.contains("has-editor");
}
set hasEditor(flag) {
return this._elMain.classList.toggle("has-editor", flag);
}
}
class NodePermissions {
constructor(edit, delete_, reply, act) {
this._edit = edit;
this._delete = delete_;
this._reply = reply;
this._act = act;
}
get edit() {
return this._edit;
}
get delete() {
return this._delete;
}
get reply() {
return this._reply;
}
get act() {
return this._act;
}
get asText() {
return [
"(",
this.edit ? "e" : "-",
this.delete ? "d" : "-",
this.reply ? "r" : "-",
this.act ? "a" : "-",
")"
].join("");
}
}
class Node {
constructor(nodeJson) {
this._el = undefined;
this._text = nodeJson.text;
this._permissions = new NodePermissions(
nodeJson.edit,
nodeJson.delete,
nodeJson.reply,
nodeJson.act,
);
this._children = new Map();
this._order = nodeJson.order;
this._order.forEach(childId => {
let childJson = nodeJson.children[childId];
let childNode = new Node(childJson);
this._children.set(childId, childNode);
});
}
child(childId) {
return this._children.get(childId);
}
get order() {
return this._order.slice();
}
// Only replaces existing children. Does not add new children.
replaceChild(childId, newChild) {
let oldChild = this.child(childId);
if (oldChild === undefined) return;
newChild.obtainElements(oldChild);
this._children.set(childId, newChild);
}
// Obtain and update this node's DOM elements. After this call, this.el
// represents the current node's contents.
//
// This function may optionally be called with an old node. If that node or
// its children already has existing DOM elements, they are repurposed.
// Otherwise, new DOM elements are created.
obtainElements(oldNode) {
if (this._el === undefined) {
// Obtain DOM elements because we don't yet have any
if (oldNode === undefined || oldNode._el === undefined) {
this._el = new NodeElements();
} else {
this._el = oldNode._el;
}
}
this._el.text = this._text;
this._el.permissions = this._permissions;
this._el.hasChildren = this.order.length > 0;
this._el.removeAllChildren();
let oldChildren = (oldNode === undefined) ? new Map() : oldNode._children;
this._order.forEach(childId => {
let oldChild = oldChildren.get(childId); // May be undefined
let child = this._children.get(childId); // Not undefined
child.obtainElements(oldChild);
this._el.addChild(child._el);
});
}
// Wrapper functions for this._el
appendTo(element) {
if (this._el === undefined) this.obtainElements();
this._el.appendTo(element);
}
get folded() {
if (this._el === undefined) return undefined;
return this._el.folded;
}
set folded(flag) {
if (this._el === undefined) return;
this._el.folded = flag;
}
toggleFolded() {
if (this._el === undefined) return;
this._el.toggleFolded();
}
get hasCursor() {
if (this._el === undefined) return undefined;
return this._el.hasCursor;
}
set hasCursor(flag) {
if (this._el === undefined) return;
this._el.hasCursor = flag;
}
get hasEditor() {
if (this._el === undefined) return undefined;
return this._el.hasEditor;
}
set hasEditor(flag) {
if (this._el === undefined) return;
this._el.hasEditor = flag;
}
}
class NodeTree {
constructor(rootNodeContainer, rootNode) {
this._rootNodeContainer = rootNodeContainer;
this._rootNode = rootNode;
// Prepare root node container
removeAllChildren(this._rootNodeContainer);
this._rootNode.appendTo(this._rootNodeContainer);
}
at(path) {
let node = this._rootNode;
for (let childId of path.components) {
node = node.child(childId);
if (node === undefined) break;
}
return node;
}
updateAt(path, newNode) {
if (path.length === 0) {
newNode.obtainElements(this._rootNode);
this._rootNode = newNode;
} else {
let parentNode = this.at(path.parent);
parentNode.replaceChild(path.last, newNode);
}
}
getChildWith(path, f) {
let node = this.at(path);
if (node === undefined) return undefined;
let index = f(node.order.length);
if (index === undefined) return undefined;
let childId = node.order[index];
if (childId === undefined) return undefined;
return path.append(childId);
}
getFirstChild(path) {
return this.getChildWith(path, l => 0);
}
getLastChild(path) {
return this.getChildWith(path, l => l - 1);
}
getSiblingWith(path, f) {
if (path.parent === undefined) return undefined;
let parentNode = this.at(path.parent);
if (parentNode === undefined) return undefined;
let index = parentNode.order.indexOf(path.last);
if (index === undefined) return undefined;
let newIndex = f(index);
if (newIndex === undefined) return undefined;
let siblingId = parentNode.order[newIndex];
if (siblingId === undefined) return undefined;
return path.parent.append(siblingId);
}
getPrevSibling(path) {
return this.getSiblingWith(path, i => i - 1);
}
getNextSibling(path) {
return this.getSiblingWith(path, i => i + 1);
}
getNodeAbove(path) {
let prevPath = this.getPrevSibling(path);
if (prevPath === undefined) return path.parent;
// Get last child of previous path
while (true) {
let prevNode = this.at(prevPath);
if (prevNode.folded) return prevPath;
let childPath = this.getLastChild(prevPath);
if (childPath === undefined) return prevPath;
prevPath = childPath;
}
}
getNodeBelow(path) {
let node = this.at(path);
if (!node.folded) {
let childPath = this.getFirstChild(path);
if (childPath !== undefined) return childPath;
}
while (path !== undefined) {
let nextPath = this.getNextSibling(path);
if (nextPath !== undefined) return nextPath;
path = path.parent;
}
return undefined;
}
}
class Cursor {
constructor(nodeTree) {
this._nodeTree = nodeTree;
this._path = new Path();
this._relPos = null; // Either null or a RelPos value
this.restore();
}
getSelectedNode() {
return this._nodeTree.at(this._path);
}
_applyRelPos() {
if (this._relPos === null) return;
let newPath;
if (this._relPos === RelPos.FIRST_CHILD) {
newPath = this._nodeTree.getFirstChild(this._path);
} else if (this._relPos === RelPos.NEXT_SIBLING) {
newPath = this._nodeTree.getNextSibling(this._path);
}
if (newPath !== undefined) {
this._path = newPath;
this._relPos = null;
}
}
_moveToNearestValidNode() {
// TODO Maybe select a sibling instead of going to nearest visible parent
let path = new Path();
for (let component of this._path.components) {
let newPath = path.append(component);
let newNode = this._nodeTree.at(newPath);
if (newNode === undefined) break;
if (newNode.folded) break;
path = newPath;
}
this._path = path;
}
_set(visible) {
this.getSelectedNode().hasCursor = visible;
}
restore() {
this._applyRelPos();
this._moveToNearestValidNode();
this._set(true);
}
moveTo(path) {
if (path === undefined) return;
this._set(false);
this._path = path;
this._set(true);
}
moveUp() {
this.moveTo(this._nodeTree.getNodeAbove(this._path));
}
moveDown() {
this.moveTo(this._nodeTree.getNodeBelow(this._path));
}
}
class Editor {
constructor(nodeTree) {
this._nodeTree = nodeTree;
this._elTextarea = newElement("textarea");
this._elTextarea.addEventListener("input", event => this._updateTextAreaHeight());
this._elMain = newElement("div", "node-editor", this.textarea);
this._path = undefined;
this._asChild = false;
}
_updateTextAreaHeight() {
this._elTextarea.style.height = 0;
this._elTextarea.style.height = this._elTextarea.scrollHeight + "px";
}
_getAttachedNode() {
if (this._path === undefined) return undefined;
return this._nodeTree.at(this._path);
}
_detach(node, asChild) {
if (!asChild) {
node.hasEditor = false;
}
this._elMain.parentNode.removeChild(this._elMain);
}
_attachTo(node, asChild) {
if (asChild) {
node._el._elChildren.appendChild(this.element);
node.folded = false;
} else {
node._el._elMain.classList.add("has-editor");
node._el._elMain.insertBefore(this.element, node._el._elChildren);
}
this._updateTextAreaHeight();
}
restore() {
if (this.element.parentNode !== null) return; // Already attached
let node = this._getAttachedNode();
if (node === undefined) return; // Nowhere to attach
this._attachTo(node, this.asChild);
}
attachTo(path, asChild) {
this.detach();
this.path = path;
this.asChild = asChild;
this.restore();
this.textarea.focus();
let length = this.textarea.value.length;
this.textarea.setSelectionRange(length, length);
}
detach() {
let node = this._getAttachedNode();
if (node === undefined) return;
this._detach(node, this.asChild);
this.path = undefined;
}
set content(text) {
this.textarea.value = text;
}
get content() {
return this.textarea.value;
}
}
class Connection {
constructor(nodeTree, cursor, editor, url) {
this.nodeTree = nodeTree;
this.cursor = cursor;
this.editor = editor;
this.url = url;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("message", msg => this.onMessage(msg));
this.ws.addEventListener("open", _ => this.sendHello());
}
onMessage(msg) {
let content = JSON.parse(msg.data);
if (content.type === "hello") {
this.onHello(content);
} else if (content.type === "update") {
this.onUpdate(content);
}
}
onHello(content) {
this.nodeTree.updateAt(new Path(), new Node(content.node));
this.cursor.restore();
this.editor.restore();
}
onUpdate(content) {
this.nodeTree.updateAt(new Path(...content.path), new Node(content.node));
this.cursor.restore();
this.editor.restore();
}
_send(thing) {
this.ws.send(JSON.stringify(thing));
}
sendHello() {
this._send({type: "hello", extensions: []});
}
sendEdit(path, text) {
this._send({type: "edit", path: path.components, text: text});
}
sendDelete(path) {
this._send({type: "delete", path: path.components});
}
sendReply(path, text) {
this._send({type: "reply", path: path.components, text: text});
}
sendAct(path) {
this._send({type: "act", path: path.components});
}
}
/*
* The main application
*/
const rootNodeContainer = document.getElementById("root-node-container");
const loadingNode = new Node({text: "Connecting...", children: {}, order: []});
const nodeTree = new NodeTree(rootNodeContainer, loadingNode);
const cursor = new Cursor(nodeTree);
const editor = new Editor(nodeTree);
const conn = new Connection(nodeTree, cursor, editor, "ws://127.0.0.1:8080/");
function beginEdit() {
let node = cursor.getSelectedNode();
editor.content = node.text;
editor.attachTo(cursor.path, false);
}
function beginDirectReply() {
editor.content = "";
editor.attachTo(cursor.path, true);
}
function beginIndirectReply() {
let path = cursor.path.parent;
if (path === undefined) return;
editor.content = "";
editor.attachTo(path, true);
}
function cancelEdit() {
editor.detach();
}
function completeEdit() {
let path = editor.path;
let text = editor.textarea.value;
if (editor.asChild) {
conn.sendReply(path, text);
} else {
conn.sendEdit(path, text);
}
editor.detach();
}
document.addEventListener("keydown", event => {
if (event.code === "Escape") {
cancelEdit();
event.preventDefault();
} else if (event.code === "Enter" && !event.shiftKey) {
completeEdit();
event.preventDefault();
} else if (document.activeElement.tagName === "TEXTAREA") {
return; // Do nothing special
} else if (event.code === "Tab") {
cursor.getSelectedNode().toggleFolded();
event.preventDefault();
} else if (event.code === "KeyK" || event.code === "ArrowUp") {
cursor.moveUp();
event.preventDefault();
} else if (event.code === "KeyJ" || event.code === "ArrowDown") {
cursor.moveDown();
event.preventDefault();
} else if (event.code === "KeyE") {
beginEdit();
event.preventDefault();
} else if (event.code === "KeyR") {
if (event.shiftKey) {
console.log("indirect");
beginIndirectReply();
} else {
console.log("direct");
beginDirectReply();
}
event.preventDefault();
} else if (event.code === "KeyD") {
conn.sendDelete(cursor.path);
event.preventDefault();
} else if (event.code === "KeyA") {
conn.sendAct(cursor.path);
event.preventDefault();
}
});

16
forest-web/settings.css Normal file
View file

@ -0,0 +1,16 @@
#settings {
position: fixed;
bottom: 0;
transition: all 0.2s ease-out;
transform: translateY(100%);
}
#settings a {
color: var(--white);
}
#settings > button, #settings > form {
padding: 1ch;
background-color: var(--magenta);
}
#settings > button {
font-weight: bold;
}

35
forest-web/settings.js Normal file
View file

@ -0,0 +1,35 @@
"use strict";
const settingsDiv = document.getElementById("settings");
const settingsButton = settingsDiv.querySelector("button");
const settingsForm = settingsDiv.querySelector("form");
let settingsMenuState;
settingsButton.addEventListener("click", event => setSettingsMenuState(!settingsMenuState));
window.addEventListener("load", event => setSettingsMenuState(false));
function setSettingsMenuState(open) {
settingsMenuState = open;
if (open) {
settingsDiv.style.transform = "none";
} else {
let height = settingsButton.offsetHeight;
settingsDiv.style.transform = `translateY(calc(100% - ${height}px))`;
}
}
const curvyLinesCheckbox = document.getElementById("curvy-lines-checkbox");
curvyLinesCheckbox.addEventListener("change", event => setCurvyLines(event.target.checked));
window.addEventListener("load", event => {
let curvy = window.localStorage.getItem("curvy");
curvyLinesCheckbox.checked = curvy;
setCurvyLines(curvy);
});
function setCurvyLines(curvy) {
document.body.classList.toggle("curvy", curvy);
if (curvy) {
window.localStorage.setItem("curvy", "yes");
} else {
window.localStorage.removeItem("curvy");
}
}

View file

@ -1,53 +0,0 @@
name: forest
version: 0.1.0.0
license: MIT
author: "Garmelon <joscha@plugh.de>"
copyright: "2020 Garmelon"
synopsis: A tree-based multi-user interaction thing
description: Please see the README on GitHub at <https://github.com/Garmelon/forest#readme>
github: "Garmelon/forest"
extra-source-files:
- README.md
- CHANGELOG.md
dependencies:
- base >= 4.7 && < 5
- aeson
- async
- brick
- containers
- microlens
- optparse-applicative
- safe
- text
- text-zipper
- transformers
- vty
- websockets
- wuss
library:
source-dirs: src
executables:
forest-server:
main: Main.hs
source-dirs: server
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- forest
forest-client:
main: Main.hs
source-dirs: client
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- forest

View file

@ -1,37 +0,0 @@
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Concurrent.MVar
import qualified Network.WebSockets as WS
import Forest.Broadcast
import Forest.Node
import Forest.Server
import Forest.TreeModule.Const
import Forest.TreeModule.Fork
import Forest.TreeModule.SharedEditing
pingDelay :: Int
pingDelay = 10
pongDelay :: Int
pongDelay = 3 * pingDelay
options :: WS.ServerOptions
options = WS.defaultServerOptions
{ WS.serverRequirePong = Just pongDelay
}
main :: IO ()
main = do
putStrLn "Preparing shared edit module"
sharedEditNodeVar <- newMVar $ txtNode "r" ""
sharedEditBroadcaster <- newBroadcaster
putStrLn "Starting server"
WS.runServerWithOptions options $ serverApp pingDelay $ forkModule "Forest"
[ ProngConstructor "Test" $ constModule $ newNode "" "" [txtNode "" "Bla"]
, ProngConstructor "Sandbox" $ sharedEditingModule sharedEditNodeVar sharedEditBroadcaster
, ProngConstructor "About" $ constModule projectDescriptionNode
]

View file

@ -1,52 +0,0 @@
-- | A 'Broadcaster' allows threads to 'broadcast' values to 'Listeners'
-- attached to that broadcaster. A value that is sent through a broadcaster will
-- arrive exactly once at each attached listener and can then be collected by
-- calling 'listen'.
--
-- All functions included in this module should be threadsafe. Be sure to read
-- the warning on the 'broadcast' function.
module Forest.Broadcast
( Broadcaster
, Listener
, newBroadcaster
, attachListener
, broadcast
, listen
) where
import Control.Concurrent.Chan
-- | A 'Broadcaster' can broadcast values to all attached 'Listener's
newtype Broadcaster a = Broadcaster (Chan a)
-- | A 'Listener' receives values from the 'Broadcaster' it is attached to
newtype Listener a = Listener (Chan a)
-- | Create a new 'Broadcaster'
newBroadcaster :: IO (Broadcaster a)
newBroadcaster = Broadcaster <$> newChan
-- | Create a new 'Listener' that is attached to a 'Broadcaster'
attachListener :: Broadcaster a -> IO (Listener a)
attachListener (Broadcaster chan) = Listener <$> dupChan chan
-- | Send a value through the 'Broadcaster'. That value will arrive exactly once
-- at all 'Listener's attached to this broadcaster via 'attachListener'.
--
-- Warning: During this function call, no exception should occur or elements may
-- build up in the broadcaster, leading to a memory/space leak.
broadcast :: Broadcaster a -> a -> IO ()
-- Because the same function that puts something into the broadcaster channel
-- also immediately reads something from that channel, there is no build-up of
-- values in the broadcaster channel, as one element is removed for each element
-- written. Since the broadcaster channel is separate from the listener
-- channels, no event is swallowed accidentally.
--
-- If some exception happens after the write operation succeeds but before the
-- read operation finishes, elements can build up in the broadcast channel.
broadcast (Broadcaster chan) value = writeChan chan value <* readChan chan
-- | Read the next value from the 'Listener'. Blocks when the listener is empty.
listen :: Listener a -> IO a
listen (Listener chan) = readChan chan

View file

@ -1,120 +0,0 @@
{-# LANGUAGE OverloadedStrings #-}
module Forest.Client.WidgetTree
( WidgetTree(..)
, renderWidgetTree
, treeLineAttr
, IndentOptions(..)
, boxDrawingBranching
, boxDrawingLine
, asciiBranching
, asciiLine
) where
import Brick
import Brick.BorderMap
import Control.Monad.Trans.Reader
import qualified Data.Text as T
import qualified Graphics.Vty as Vty
import Lens.Micro
data WidgetTree n = WidgetTree (Widget n) [WidgetTree n]
addLoc :: Location -> Location -> Location
addLoc l1 l2 =
let (x1, y1) = loc l1
(x2, y2) = loc l2
in Location (x1 + x2, y1 + y2)
offsetResult :: Location -> Result n -> Result n
offsetResult offset result = result
{ cursors = map offsetCursor $ cursors result
, visibilityRequests = map offsetVr $ visibilityRequests result
, extents = map offsetExtent $ extents result
, borders = translate offset $ borders result
}
where
offsetCursor c = c{cursorLocation = addLoc offset $ cursorLocation c}
offsetVr vr = vr{vrPosition = addLoc offset $ vrPosition vr}
offsetExtent e = e
{ extentUpperLeft = addLoc offset $ extentUpperLeft e
, extentOffset = addLoc offset $ extentOffset e
}
indentWith :: T.Text -> T.Text -> Widget n -> Widget n
indentWith firstLine otherLines wrapped = Widget
{ hSize = hSize wrapped
, vSize = vSize wrapped
, render = renderWidget
}
where
maxWidth = max (T.length firstLine) (T.length otherLines)
renderWidget = do
context <- ask
result <- render $ hLimit (availWidth context - maxWidth) wrapped
let attribute = attrMapLookup treeLineAttr $ context ^. ctxAttrMapL
resultHeight = Vty.imageHeight $ image result
textLines = firstLine : replicate (resultHeight - 1) otherLines
leftImage = Vty.vertCat $ map (Vty.text' attribute) textLines
newImage = leftImage Vty.<|> image result
newResult = offsetResult (Location (maxWidth, 0)) $ result{image=newImage}
pure newResult
indent :: IndentOptions -> [Widget n] -> Widget n
indent opts widgets = vBox $ reverse $ case reverse widgets of
[] -> []
(w:ws) ->
indentWith (lastBranch opts) (afterLastBranch opts) w :
map (indentWith (inlineBranch opts) (noBranch opts)) ws
renderWidgetTree :: IndentOptions -> WidgetTree n -> Widget n
renderWidgetTree opts (WidgetTree node children) =
node <=> indent opts (map (renderWidgetTree opts) children)
treeLineAttr :: AttrName
treeLineAttr = "treeLine"
-- | These options control how a tree is rendered. For more information on how
-- the various options are used, try rendering a tree with 'boxDrawingBranhing'
-- and inspect the results.
--
-- Warning: The options *must* be single line strings and *must not* contain
-- newlines of any sort.
data IndentOptions = IndentOptions
{ noBranch :: T.Text
, inlineBranch :: T.Text
, lastBranch :: T.Text
, afterLastBranch :: T.Text
} deriving (Show, Eq)
boxDrawingBranching :: IndentOptions
boxDrawingBranching = IndentOptions
{ noBranch = ""
, inlineBranch = "├╴"
, lastBranch = "└╴"
, afterLastBranch = " "
}
boxDrawingLine :: IndentOptions
boxDrawingLine = IndentOptions
{ noBranch = ""
, inlineBranch = ""
, lastBranch = ""
, afterLastBranch = ""
}
asciiBranching :: IndentOptions
asciiBranching = IndentOptions
{ noBranch = "| "
, inlineBranch = "+-"
, lastBranch = "+-"
, afterLastBranch = " "
}
asciiLine :: IndentOptions
asciiLine = IndentOptions
{ noBranch = "| "
, inlineBranch = "| "
, lastBranch = "| "
, afterLastBranch = "| "
}

View file

@ -1,71 +0,0 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
module Forest.Server
( withThread
, serverApp
) where
import Control.Concurrent.Chan
import Control.Exception
import qualified Network.WebSockets as WS
import Forest.Api
import Forest.Node
import Forest.TreeModule
import Forest.Util
{- Thread that sends updates to the client -}
sendUpdatesThread :: WS.Connection -> Chan Node -> Node -> IO ()
sendUpdatesThread conn nodeChan nodeA = do
nodeB <- readChan nodeChan
case diffNodes nodeA nodeB of
Nothing -> do
putStrLn "Sending no update because the node didn't change"
sendUpdatesThread conn nodeChan nodeA
Just (path, nextNode) -> do
putStrLn $ "Sending partial update for path " ++ show path ++ ": " ++ show nextNode
sendPacket conn $ ServerUpdate path nextNode
sendUpdatesThread conn nodeChan nodeB
{- Main server application that receives and processes client packets -}
receivePackets :: TreeModule a () => WS.Connection -> a () -> IO ()
receivePackets conn treeModule = runUntilJustM $ do
packet <- receivePacket conn
case packet of
ClientEdit path text -> do
putStrLn $ "Editing " ++ show path ++ " to " ++ show text
edit treeModule path text
ClientDelete path -> do
putStrLn $ "Deleting " ++ show path
delete treeModule path
ClientReply path text -> do
putStrLn $ "Replying to " ++ show path ++ " with " ++ show text
reply treeModule path text
ClientAct path -> do
putStrLn $ "Acting upon " ++ show path
act treeModule path
ClientHello _ -> closeWithErrorMessage conn "Invalid packet: Hello can only be sent once"
printException :: SomeException -> IO ()
printException e = putStrLn $ "Encountered exception: " ++ show e
serverApp :: TreeModule a () => Int -> ModuleConstructor (a ()) -> WS.ServerApp
serverApp pingDelay constructor pendingConnection = do
conn <- WS.acceptRequest pendingConnection
chan <- newChan
WS.withPingThread conn pingDelay (pure ()) $ handle printException $ do
firstPacket <- receivePacket conn
case firstPacket of
ClientHello _ -> do
putStrLn $ "Sending hello reply with " ++ show initialNode
sendPacket conn $ ServerHello [] initialNode
withThread (sendUpdatesThread conn chan initialNode) $
constructor (writeChan chan) $ \tm -> do
receivePackets conn tm
putStrLn "Module finished, closing connection"
_ -> closeWithErrorMessage conn "Invalid packet: Expected a hello packet"
where
initialNode = txtNode "" "Loading..."

View file

@ -1,25 +0,0 @@
{-# LANGUAGE MultiParamTypeClasses #-}
module Forest.TreeModule
( TreeModule(..)
, ModuleConstructor
) where
import qualified Data.Text as T
import Forest.Node
class TreeModule a r where
edit :: a r -> Path -> T.Text -> IO (Maybe r)
edit _ _ _ = pure Nothing
delete :: a r -> Path -> IO (Maybe r)
delete _ _ = pure Nothing
reply :: a r -> Path -> T.Text -> IO (Maybe r)
reply _ _ _ = pure Nothing
act :: a r -> Path -> IO (Maybe r)
act _ _ = pure Nothing
type ModuleConstructor a = (Node -> IO ()) -> (a -> IO ()) -> IO ()

View file

@ -1,27 +0,0 @@
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
module Forest.TreeModule.Animate
( AnimateModule
, animateModule
) where
import Control.Concurrent
import Forest.Node
import Forest.TreeModule
import Forest.Util
data AnimateModule r = AnimateModule
instance TreeModule AnimateModule () where
animateModule :: Int -> [Node] -> ModuleConstructor (AnimateModule ())
animateModule delay frames sendNode continue =
withThread (animateThread frames) $ continue AnimateModule
where
animateThread [] = sendNode $ txtNode "" "Invalid animation: No frames provided"
animateThread (x:xs) = do
sendNode x
threadDelay delay
animateThread $ xs ++ [x]

View file

@ -1,118 +0,0 @@
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
module Forest.TreeModule.Const
( ConstModule
, constModule
, projectDescriptionNode
) where
import Forest.Node
import Forest.TreeModule
data ConstModule r = ConstModule
instance TreeModule ConstModule () where
constModule :: Node -> ModuleConstructor (ConstModule ())
constModule node sendNode continue = do
sendNode node
continue ConstModule
projectDescriptionNode :: Node
projectDescriptionNode =
newNode "" "About"
[ txtNode "" "This project is an experiment in tree-based interaction."
, newNode "" "Motivation"
[ txtNode "" "My goals for this project were:"
, newNode "" "Interactons between multiple people"
[ txtNode ""
( "I wanted to create a project that let multiple people interact with "
<> "each other in different ways. Examples for interactions include:\n"
<> "* Chatting\n"
<> "* Collaborative editing\n"
<> "* Playing (multiplayer) games\n"
)
, txtNode "" "The project should allow for many different kinds of interactions."
]
, newNode "" "Portability"
[ txtNode ""
( "The project should be usable on multiple different platforms. To "
<> "facilitate this, clients should be easy to create. In particular, I "
<> "want at least one terminal-based and one web-based client."
)
]
, txtNode "" "Based on these goals, I made the following design decisions:"
, newNode "" "Text-based"
[ txtNode ""
( "Text is a medium that works on all platforms and easy to work with "
<> "as a developer."
)
, txtNode ""
( "But text still allows for a lot of different interactions. Of all "
<> "the kinds of media one can produce with a computer, text is easy "
<> "and quick to create. After all, pretty much every computer has a "
<> "keyboard."
)
]
, newNode "" "Tree-based"
[ txtNode ""
( "While plain text may be easy to work with, it makes interactions "
<> "cumbersome if limited to basic input and output. To make "
<> "interactions nicer, the server could send the client a screen's "
<> "worth of text to display, in effect creating a TUI-like interface. "
<> "The client would then only need to send key presses or mouse clicks "
<> "to the server."
)
, txtNode ""
( "In my opinion, that approach moves too many decisions on how to "
<> "interact to the server and imposes unnecessary limits on the client "
<> "design. Instead, I went with a plaintext-in-tree-structure "
<> "approach, which allows for more flexibility in the client design. "
<> "Also, this should make bots easier to write, since they don't have "
<> "to emulate human input."
)
]
, newNode "" "Simple API"
[ txtNode ""
( "Every client must use the same API to interact with the server. "
<> "Because clients should be easy to create on different platforms, "
<> "the API should also be simple."
)
, txtNode ""
( "One way in which the API is simple is that the server doesn't send "
<> "direct responses to client commands. Instead, there is only the "
<> "'update' packet, which is sent whenever the client should modify "
<> "its tree structure."
)
, txtNode ""
( "In total, there are 5 different client packages and 2 different "
<> "server packages. If at some point the API turns out to be too "
<> "simple, it has a built-in way of negotiating protocol extensions."
)
]
, newNode "" "Most logic in server"
[ txtNode ""
( "All logic besides the immediate input handling and tree folding "
<> "happens in the server. This has multiple advantages:"
)
, txtNode "" "The API and clients are simpler, clients are easier to write or maintain."
, txtNode "" "Updates in logic don't require updates of the client."
, txtNode "" "The server-side logic becomes easier to write."
]
, txtNode ""
( "Those design decisions should allow for various different kinds of "
<> "interactions, for example linear and threaded chat, collaborative "
<> "node editing, reading node-based documents (like this one), playing "
<> "text adventures and more."
)
, txtNode ""
( "And of course, which interactions are supported only depends on the "
<> "server and not on the client."
)
]
, newNode "" "Inspirations"
[ txtNode "" "The tree-based chat model and UI of euphoria (euphoria.io) and instant (instant.leet.nu)"
, txtNode "" "MUDs (which are text based and most of the logic happens server-side)"
]
]

View file

@ -1,102 +0,0 @@
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RankNTypes #-}
module Forest.TreeModule.Fork
( ForkModule
, ProngConstructor(..)
, forkModule
) where
import Control.Concurrent.MVar
import Control.Monad.Trans.Cont
import qualified Data.Map as Map
import qualified Data.Text as T
import Forest.Node
import qualified Forest.OrderedMap as OMap
import Forest.TreeModule
data Prong = forall r a . TreeModule a r => Prong (a r)
data ProngConstructor = forall r a . TreeModule a r =>
ProngConstructor T.Text (ModuleConstructor (a r))
newtype ForkModule r = ForkModule (Map.Map NodeId Prong)
instance TreeModule ForkModule () where
edit _ (Path []) _ = pure Nothing
edit (ForkModule prongs) (Path (x:xs)) text = case prongs Map.!? x of
Nothing -> pure Nothing
Just (Prong a) -> do
result <- edit a (Path xs) text
pure $ () <$ result
delete _ (Path []) = pure Nothing
delete (ForkModule prongs) (Path (x:xs)) = case prongs Map.!? x of
Nothing -> pure Nothing
Just (Prong a) -> do
result <- delete a (Path xs)
pure $ () <$ result
reply _ (Path []) _ = pure Nothing
reply (ForkModule prongs) (Path (x:xs)) text = case prongs Map.!? x of
Nothing -> pure Nothing
Just (Prong a) -> do
result <- reply a (Path xs) text
pure $ () <$ result
act _ (Path []) = pure Nothing
act (ForkModule prongs) (Path (x:xs)) = case prongs Map.!? x of
Nothing -> pure Nothing
Just (Prong a) -> do
result <- act a (Path xs)
pure $ () <$ result
data ProngInfo = ProngInfo
{ piTopName :: T.Text
, piNames :: Map.Map NodeId T.Text
, piNodes :: Map.Map NodeId Node
, piOrder :: [NodeId]
}
renderProngInfo :: ProngInfo -> Node
renderProngInfo pInfo =
let childMap = Map.intersectionWith
(\name node -> node{nodeText = name})
(piNames pInfo)
(piNodes pInfo)
children = OMap.fromMapWithOrder childMap $ piOrder pInfo
in Node {nodeText = piTopName pInfo, nodeFlags = mempty, nodeChildren = children}
sendNodeFromProng :: MVar ProngInfo -> (Node -> IO ()) -> NodeId -> Node -> IO ()
sendNodeFromProng piVar sendNode nodeId node =
modifyMVar_ piVar $ \pInfo -> do
let newPInfo = pInfo {piNodes = Map.insert nodeId node $ piNodes pInfo}
sendNode $ renderProngInfo newPInfo
pure newPInfo
constructProngs
:: MVar ProngInfo
-> (Node -> IO ())
-> Map.Map NodeId ProngConstructor
-> Cont (IO ()) (Map.Map NodeId Prong)
constructProngs piVar sendNode =
Map.traverseWithKey constructProng
where
constructProng nodeId (ProngConstructor _ constructor) =
Prong <$> cont (constructor $ sendNodeFromProng piVar sendNode nodeId)
forkModule :: T.Text -> [ProngConstructor] -> ModuleConstructor (ForkModule ())
forkModule text prongs sendNode continue = do
let namePairs = zip enumerateIds $ map (\(ProngConstructor name _) -> name) prongs
nodesVar <- newMVar ProngInfo
{ piTopName = text
, piNames = Map.fromList namePairs
, piNodes = Map.empty
, piOrder = map fst namePairs
}
let numbers = map (T.pack . show) [(0::Integer)..]
prongMap = Map.fromList $ zip numbers prongs
runCont (constructProngs nodesVar sendNode prongMap) (continue . ForkModule)

View file

@ -1,56 +0,0 @@
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
module Forest.TreeModule.SharedEditing
( SharedEditingModule
, sharedEditingModule
) where
import Control.Concurrent.MVar
import Control.Monad
import Forest.Broadcast
import Forest.Node
import Forest.TreeModule
import Forest.Util
data SharedEditingModule r = SharedEditingModule
{ seNodeVar :: MVar Node
, seBroadcaster :: Broadcaster Node
}
instance TreeModule SharedEditingModule r where
edit _ (Path []) _ = pure Nothing
edit se path text = do
node' <- modifyMVar (seNodeVar se) $ \node -> do
let updatedNode = adjustAt (\n -> n{nodeText = text}) path node
pure (updatedNode, updatedNode)
broadcast (seBroadcaster se) node'
pure Nothing
delete _ (Path []) = pure Nothing
delete se path = do
node' <- modifyMVar (seNodeVar se) $ \node -> do
let updatedNode = deleteAt path node
pure (updatedNode, updatedNode)
broadcast (seBroadcaster se) node'
pure Nothing
reply se path text = do
node' <- modifyMVar (seNodeVar se) $ \node -> do
let updatedNode = appendAt (txtNode "edr" text) path node
pure (updatedNode, updatedNode)
broadcast (seBroadcaster se) node'
pure Nothing
sharedEditingModule ::
MVar Node -> Broadcaster Node -> ModuleConstructor (SharedEditingModule ())
sharedEditingModule nodeVar broadcaster sendNode continue = do
listener <- attachListener broadcaster
withThread (updateOnNewBroadcast listener) $ do
withMVar nodeVar sendNode -- We need to show our initial edit state
continue $ SharedEditingModule nodeVar broadcaster
where
updateOnNewBroadcast listener = forever $ do
node <- listen listener
sendNode node

View file

@ -1,66 +1,6 @@
# This file was automatically generated by 'stack init'
#
# Some commonly used options have been documented as comments in this file.
# For advanced use and comprehensive documentation of the format, please see:
# https://docs.haskellstack.org/en/stable/yaml_configuration/
# Resolver to choose a 'specific' stackage snapshot or a compiler version.
# A snapshot resolver dictates the compiler version and the set of packages
# to be used for project dependencies. For example:
#
# resolver: lts-3.5
# resolver: nightly-2015-09-21
# resolver: ghc-7.10.2
#
# The location of a snapshot can be provided as a file or url. Stack assumes
# a snapshot provided as a file might change, whereas a url resource does not.
#
# resolver: ./custom-snapshot.yaml
# resolver: https://example.com/snapshots/2018-01-01.yaml
resolver: lts-15.1
# User packages to be built.
# Various formats can be used as shown in the example below.
#
# packages:
# - some-directory
# - https://example.com/foo/bar/baz-0.0.2.tar.gz
# subdirs:
# - auto-update
# - wai
resolver: lts-15.3
packages:
- .
# Dependency packages to be pulled from upstream that are not in the resolver.
# These entries can reference officially published versions as well as
# forks / in-progress versions pinned to a git hash. For example:
#
# extra-deps:
# - acme-missiles-0.3
# - git: https://github.com/commercialhaskell/stack.git
# commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a
#
# extra-deps: []
# Override default flag values for local packages and extra-deps
# flags: {}
# Extra package databases containing global packages
# extra-package-dbs: []
# Control whether we use the GHC we find on the path
# system-ghc: true
#
# Require a specific version of stack, using version ranges
# require-stack-version: -any # Default
# require-stack-version: ">=2.1"
#
# Override the architecture used by stack, especially useful on Windows
# arch: i386
# arch: x86_64
#
# Extra directories used by stack for building
# extra-include-dirs: [/path/to/dir]
# extra-lib-dirs: [/path/to/dir]
#
# Allow a newer minor version of GHC than the snapshot specifies
# compiler-check: newer-minor
- forest-cabin
- forest-common
- forest-server
- forest-tui

View file

@ -6,7 +6,7 @@
packages: []
snapshots:
- completed:
size: 489011
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/15/1.yaml
sha256: d4ecc42b7125d68e4c3c036a08046ad0cd02ae0d9efbe3af2223a00ff8cc16f3
original: lts-15.1
size: 491373
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/15/3.yaml
sha256: 29e9ff61b8bf4b4fcff55cde3ac106ebb971f0d21331dccac9eee63374fa6ca8
original: lts-15.3