From 31a538c761929cabbe1514ddbf3a4f10a063e266 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 18 Sep 2021 17:04:36 -0400 Subject: [PATCH 01/48] Initial work on new fsm --- lib/functional.fnl | 10 +++ lib/modal.fnl | 6 +- lib/new-statemachine.fnl | 138 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 lib/new-statemachine.fnl diff --git a/lib/functional.fnl b/lib/functional.fnl index 62a2faf..462e09a 100644 --- a/lib/functional.fnl +++ b/lib/functional.fnl @@ -206,6 +206,14 @@ (let [filtered (filter f tbl)] (<= 1 (length filtered)))) +(fn push + [tbl e] + (concat tbl [e])) + +(fn pop + [tbl] + (slice 1 -1 tbl)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Others @@ -246,6 +254,8 @@ : map : merge : noop + : pop + : push : reduce : seq : seq? diff --git a/lib/modal.fnl b/lib/modal.fnl index 82be3f4..096a40b 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -22,6 +22,8 @@ switching menus in one place which is then powered by config.fnl. : map : merge : noop + : push + : pop : slice} (require :lib.functional)) (local {:align-columns align-columns} @@ -272,7 +274,7 @@ switching menus in one place which is then powered by config.fnl. :stop-timeout :nil :unbind-keys (bind-menu-keys menu.items) :history (if history - (concat [] history [menu]) + (push history menu) [menu])}) @@ -438,7 +440,7 @@ switching menus in one place which is then powered by config.fnl. (show-modal-menu (merge state {:menu prev-menu :prev-menu menu})) - {:history (slice 1 -1 history)}) + {:history (pop history)}) (idle->active state)))) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl new file mode 100644 index 0000000..1af7190 --- /dev/null +++ b/lib/new-statemachine.fnl @@ -0,0 +1,138 @@ +(local atom (require :lib.atom)) +(local {: merge + : concat + : push + : pop + : slice} (require :lib.functional)) + +(local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) + +;; +;; Schema +;; { :current-state ; An atom keyword +;; :states {:state1 {} +;; :state2 {} +;; :state3 { +;; ; TODO: Do we want :enter and :exit, or let the effects +;; ; callback handle it +;; :transitions {:leave :state2 +;; :enter :state3} +;; :state4 {} +;; }}} +;; :transitions} ; takes in fsm & event +;; ; TODO: Could this :context be completely separate from the FSM? Since only +;; ; `effects` callbacks should touch it. How it is provided to them, though?' +;; :context ; an atom that tracks extra data e.g. current app, history, etc. +;; +(fn set-state + [fsm state] + (atom.swap! fsm.current-state (fn [_ state] state) state)) + +(fn signal + [fsm action extra] + "Based on the action and the fsm's current-state, set the new state and call + the effects listener with the old state, new state, action, and extra" + (let [current-state (atom.deref fsm.current-state) + next-state (. fsm.states current-state :transitions action) + effects fsm.effects] + ; If next-state is nil, error: Means the action is not expected in this state + (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s" (atom.deref fsm.current-state) next-state action extra) ;; DELETEME + (if next-state + (do + (set-state fsm next-state) + ; TODO: Should we let this callback decide on the new state? But there + ; can be multiple listeners + ; TODO: Provide whole FSM or just context? + (effects fsm.context current-state next-state action extra)) + (log.wf "Action :%s is not defined in state :%s" action current-state)))) + +(fn create-machine + [states initial-state] + (merge {:current-state (atom.new initial-state) + :context (atom.new states.context)} + states)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Example +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(var modal-fsm nil) +(fn enter-menu + [context menu] + (log.wf "XXX Enter menu %s. Current stack: %s" menu (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME + ; TODO: Show the actual menu + ; TODO: Bind keys according to actual menu + (alert "menu") + (atom.swap! context.menu-stack (fn [stack menu] (push stack menu)) menu) + (hs.hotkey.bind [:cmd] "l" (fn [] (signal modal-fsm :leave))) + ; Down a menu deeper + (hs.hotkey.bind [:cmd] "d" + (fn [] (signal modal-fsm :select (tostring (length (atom.deref context.menu-stack)))))) + ; Up a menu + (hs.hotkey.bind [:cmd] "u" (fn [] (signal modal-fsm :back)))) + +(fn up-menu + [context menu] + "Go up a menu in the stack. If we are the last menu, then we must fire an + event to :leave" + (log.wf "XXX Up menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME + ; TODO: Unbind keys from this menu + (let [stack (atom.deref (atom.swap! context.menu-stack (fn [stack] (pop stack))))] + (when (= (length stack) 0) (signal modal-fsm :leave)))) + +(fn leave-menu + [context] + (log.wf "XXX Leave menu") ;; DELETEME + (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME + ; TODO: Unbind keys from this menu + (atom.swap! context.menu-stack (fn [_ menu] [])) + ) + +(fn modal-action + ; 'extra' would be the key hit, or name of the action, so we know which + ; submenu to enter, for example. A menu with 4 options would bind each to a + ; function calling (signal :enter), but each with their own 'extra'. Maybe the + ; key hit or the menu itself + [context old-state new-state action extra] + (log.wf "XXX Got action :%s with extra %s while in :%s, transitioning to :%s" action extra old-state new-state) ;; DELETEME + (if (= old-state :idle) + (if (= action :leave) nil + (= action :activate) (enter-menu context :main)) + (= old-state :menu) + (if (= action :leave) (leave-menu context) + (= action :back) (up-menu context extra) + ; TODO: Which menu? Does enter-menu figure it out or do we? + (= action :select) (enter-menu context extra)))) + + +(local modal-states + {:states {:idle {:enter nil + :exit nil + :transitions {:leave :idle + :activate :menu}} + :menu {:enter nil + :exit nil + ; TODO: How can we allow a transition to a previous menu + :transitions { + ; Leave dumps all menus + :leave :idle + ; Back pops a menu off the stack + :back :menu + ; Select pushes a menu on the stack + :select :menu}}} + ; TODO: This would be an event stream dispatcher or publish func + :effects modal-action + :context {:modal {:modal nil + :stop-func nil} + ; TODO: This would be filled based on config + :menu-hierarchy nil + :menu-stack (atom.new [])}}) + +; This creates an atom for current-state and context +(set modal-fsm (create-machine modal-states :idle)) + +; Debuging bindings +(hs.hotkey.bind [:cmd] :s (fn [] (log.wf "XXX Current stack: %s" (hs.inspect (atom.deref modal-fsm.context.menu-stack))))) ;; DELETEME + +{: signal + :modal-fsm modal-fsm ;; DELETEME + :new create-machine} From c674d13471d932761fee08375d30a505ecf301b2 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 19 Sep 2021 12:17:43 -0400 Subject: [PATCH 02/48] pop/push -> butlast/conf --- lib/functional.fnl | 12 +++++++----- lib/modal.fnl | 10 +++++----- lib/new-statemachine.fnl | 10 +++++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/functional.fnl b/lib/functional.fnl index 462e09a..af7117c 100644 --- a/lib/functional.fnl +++ b/lib/functional.fnl @@ -206,12 +206,14 @@ (let [filtered (filter f tbl)] (<= 1 (length filtered)))) -(fn push +(fn conj [tbl e] + "Return a new list with the element e added at the end" (concat tbl [e])) -(fn pop +(fn butlast [tbl] + "Return a new list with all but the last item" (slice 1 -1 tbl)) @@ -234,9 +236,11 @@ ;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{: call-when +{: butlast + : call-when : compose : concat + : conj : contains? : count : eq? @@ -254,8 +258,6 @@ : map : merge : noop - : pop - : push : reduce : seq : seq? diff --git a/lib/modal.fnl b/lib/modal.fnl index 096a40b..28707c8 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -12,8 +12,10 @@ switching menus in one place which is then powered by config.fnl. (local atom (require :lib.atom)) (local statemachine (require :lib.statemachine)) (local apps (require :lib.apps)) -(local {: call-when +(local {: butlast + : call-when : concat + : conj : find : filter : has-some? @@ -22,8 +24,6 @@ switching menus in one place which is then powered by config.fnl. : map : merge : noop - : push - : pop : slice} (require :lib.functional)) (local {:align-columns align-columns} @@ -274,7 +274,7 @@ switching menus in one place which is then powered by config.fnl. :stop-timeout :nil :unbind-keys (bind-menu-keys menu.items) :history (if history - (push history menu) + (conj history menu) [menu])}) @@ -440,7 +440,7 @@ switching menus in one place which is then powered by config.fnl. (show-modal-menu (merge state {:menu prev-menu :prev-menu menu})) - {:history (pop history)}) + {:history (butlast history)}) (idle->active state)))) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 1af7190..1081897 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -1,8 +1,8 @@ (local atom (require :lib.atom)) -(local {: merge +(local {: butlast : concat - : push - : pop + : conj + : merge : slice} (require :lib.functional)) (local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) @@ -62,7 +62,7 @@ ; TODO: Show the actual menu ; TODO: Bind keys according to actual menu (alert "menu") - (atom.swap! context.menu-stack (fn [stack menu] (push stack menu)) menu) + (atom.swap! context.menu-stack (fn [stack menu] (conj stack menu)) menu) (hs.hotkey.bind [:cmd] "l" (fn [] (signal modal-fsm :leave))) ; Down a menu deeper (hs.hotkey.bind [:cmd] "d" @@ -76,7 +76,7 @@ event to :leave" (log.wf "XXX Up menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME ; TODO: Unbind keys from this menu - (let [stack (atom.deref (atom.swap! context.menu-stack (fn [stack] (pop stack))))] + (let [stack (atom.deref (atom.swap! context.menu-stack (fn [stack] (butlast stack))))] (when (= (length stack) 0) (signal modal-fsm :leave)))) (fn leave-menu From 04e620f66c855d7e71306a382b7693eb3677c4c2 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 19 Sep 2021 12:59:45 -0400 Subject: [PATCH 03/48] fsm2: Simplify schema and add subscribe functionality --- lib/new-statemachine.fnl | 91 ++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 1081897..338de6a 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -12,11 +12,11 @@ ;; { :current-state ; An atom keyword ;; :states {:state1 {} ;; :state2 {} -;; :state3 { +;; :state3 ;; ; TODO: Do we want :enter and :exit, or let the effects ;; ; callback handle it -;; :transitions {:leave :state2 -;; :enter :state3} +;; {:leave :state2 +;; :enter :state3} ;; :state4 {} ;; }}} ;; :transitions} ; takes in fsm & event @@ -31,25 +31,45 @@ (fn signal [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call - the effects listener with the old state, new state, action, and extra" + the all subscribers with the old state, new state, action, and extra" + (log.wf "signal action :%s" action) ;; DELETEME (let [current-state (atom.deref fsm.current-state) - next-state (. fsm.states current-state :transitions action) - effects fsm.effects] + next-state ((. fsm.states current-state action) fsm.context action extra) + effect next-state.effect] ; If next-state is nil, error: Means the action is not expected in this state - (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s" (atom.deref fsm.current-state) next-state action extra) ;; DELETEME + (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" (atom.deref fsm.current-state) (hs.inspect next-state) action extra effect) ;; DELETEME (if next-state (do (set-state fsm next-state) ; TODO: Should we let this callback decide on the new state? But there ; can be multiple listeners ; TODO: Provide whole FSM or just context? - (effects fsm.context current-state next-state action extra)) + (each [_ sub (pairs (atom.deref fsm.subscribers))] + (log.wf "Calling sub %s" sub) ;; DELETEME + (sub {:prev-state current-state :next-state next-state :effect effect})) + ) (log.wf "Action :%s is not defined in state :%s" action current-state)))) +(fn subscribe + [fsm sub] + "Adds a subscriber to the provided fsm. Returns a function to unsubscribe" + ; Super naive: Returns a function that just removes the entry at the inserted + ; key, but doesn't allow the same function to subscribe more than once since + ; its keyed by the string of the function itself. + (let [sub-key (tostring sub)] + (log.wf "Adding subscriber %s" sub) ;; DELETEME + (atom.swap! fsm.subscribers (fn [subs sub] + (merge {sub-key sub} subs)) sub) + ; Return the unsub func + (fn [] + (atom.swap! fsm.subscribers (fn [subs key] (tset subs key nil)) sub-key)))) + (fn create-machine [states initial-state] (merge {:current-state (atom.new initial-state) - :context (atom.new states.context)} + :context (atom.new states.context) + ; TODO: Use something less naive for subscribers + :subscribers (atom.new {})} states)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Example @@ -59,16 +79,11 @@ (fn enter-menu [context menu] (log.wf "XXX Enter menu %s. Current stack: %s" menu (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME - ; TODO: Show the actual menu - ; TODO: Bind keys according to actual menu - (alert "menu") (atom.swap! context.menu-stack (fn [stack menu] (conj stack menu)) menu) - (hs.hotkey.bind [:cmd] "l" (fn [] (signal modal-fsm :leave))) - ; Down a menu deeper - (hs.hotkey.bind [:cmd] "d" - (fn [] (signal modal-fsm :select (tostring (length (atom.deref context.menu-stack)))))) - ; Up a menu - (hs.hotkey.bind [:cmd] "u" (fn [] (signal modal-fsm :back)))) + {:current-state :menu + :context {:history [] + :menu :main-menu} + :effect :modal-opened}) (fn up-menu [context menu] @@ -96,7 +111,7 @@ (log.wf "XXX Got action :%s with extra %s while in :%s, transitioning to :%s" action extra old-state new-state) ;; DELETEME (if (= old-state :idle) (if (= action :leave) nil - (= action :activate) (enter-menu context :main)) + (= action :open) (enter-menu context :main)) (= old-state :menu) (if (= action :leave) (leave-menu context) (= action :back) (up-menu context extra) @@ -105,22 +120,15 @@ (local modal-states - {:states {:idle {:enter nil - :exit nil - :transitions {:leave :idle - :activate :menu}} - :menu {:enter nil - :exit nil - ; TODO: How can we allow a transition to a previous menu - :transitions { - ; Leave dumps all menus - :leave :idle - ; Back pops a menu off the stack - :back :menu - ; Select pushes a menu on the stack - :select :menu}}} - ; TODO: This would be an event stream dispatcher or publish func - :effects modal-action + {:states {:idle {:leave :idle + :open (fn [context action extra] + {:current-state :menu + :context {:history [] + :menu :main-menu} + :effect :modal-opened})} + :menu {:leave leave-menu + :back up-menu + :select enter-menu}} :context {:modal {:modal nil :stop-func nil} ; TODO: This would be filled based on config @@ -128,11 +136,22 @@ :menu-stack (atom.new [])}}) ; This creates an atom for current-state and context +; TODO: We could require the initiali state me a key in the states map +; TODO: If we preserve the initial context we can maybe fsm.reset, thoug that's +; hard to do safely since it only restores state and context, not the state of +; hammerspoon itself, e.g. keys bindings, that have been messed with with all +; the signal handlers. (set modal-fsm (create-machine modal-states :idle)) +(local unsub-display (subscribe modal-fsm (fn [] (alert "MENU HERE")))) +(local unsub-bind (subscribe modal-fsm (fn [] (log.wf "Binding keys...")))) +(log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME +;; (unsub) ;; DELETEME +;; (log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME ; Debuging bindings (hs.hotkey.bind [:cmd] :s (fn [] (log.wf "XXX Current stack: %s" (hs.inspect (atom.deref modal-fsm.context.menu-stack))))) ;; DELETEME {: signal - :modal-fsm modal-fsm ;; DELETEME + : modal-fsm ;; DELETEME + : subscribe :new create-machine} From 278d7600b9724b63bff2244e5c219145522ccabc Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 19 Sep 2021 14:45:56 -0400 Subject: [PATCH 04/48] Add and tweak Jay's effect-handler --- lib/new-statemachine.fnl | 182 ++++++++++++++++++++++++--------------- 1 file changed, 111 insertions(+), 71 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 338de6a..4b0bfed 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -1,7 +1,9 @@ (local atom (require :lib.atom)) (local {: butlast + : call-when : concat : conj + : last : merge : slice} (require :lib.functional)) @@ -10,21 +12,17 @@ ;; ;; Schema ;; { :current-state ; An atom keyword +;; ; States table: A map of state names to a map of actions to functions +;; ; These functions must return a map containing the new state keyword, the +;; ; effect, and a new context ;; :states {:state1 {} ;; :state2 {} -;; :state3 -;; ; TODO: Do we want :enter and :exit, or let the effects -;; ; callback handle it -;; {:leave :state2 -;; :enter :state3} -;; :state4 {} -;; }}} +;; :state3 {:leave :state2 +;; :enter :state3}}}} ;; :transitions} ; takes in fsm & event -;; ; TODO: Could this :context be completely separate from the FSM? Since only -;; ; `effects` callbacks should touch it. How it is provided to them, though?' ;; :context ; an atom that tracks extra data e.g. current app, history, etc. ;; -(fn set-state +(fn set-current-state [fsm state] (atom.swap! fsm.current-state (fn [_ state] state) state)) @@ -32,23 +30,25 @@ [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call the all subscribers with the old state, new state, action, and extra" - (log.wf "signal action :%s" action) ;; DELETEME (let [current-state (atom.deref fsm.current-state) - next-state ((. fsm.states current-state action) fsm.context action extra) - effect next-state.effect] + _ (log.wf "XXX Current state %s" current-state) ;; DELETEME + ; TODO: Better name? This is the map that contains old, new, effect, etc. + ; TODO: Handle a signal with no handler + transition ((. fsm.states current-state action) fsm.context action extra) + _ (log.wf "XXX received transition info %s" (hs.inspect transition)) ;; DELETEME + next-state transition.current-state + new-context transition.context + _ (log.wf "XXX next state %s" next-state) ;; DELETEME + effect transition.effect] ; If next-state is nil, error: Means the action is not expected in this state - (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" (atom.deref fsm.current-state) (hs.inspect next-state) action extra effect) ;; DELETEME - (if next-state - (do - (set-state fsm next-state) + (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" current-state next-state action extra effect) ;; DELETEME + + (set-current-state fsm next-state) ; TODO: Should we let this callback decide on the new state? But there ; can be multiple listeners ; TODO: Provide whole FSM or just context? - (each [_ sub (pairs (atom.deref fsm.subscribers))] - (log.wf "Calling sub %s" sub) ;; DELETEME - (sub {:prev-state current-state :next-state next-state :effect effect})) - ) - (log.wf "Action :%s is not defined in state :%s" action current-state)))) + (each [_ sub (pairs (atom.deref fsm.subscribers))] + (sub {:context new-context :prev-state current-state :next-state next-state :effect effect :extra extra})))) (fn subscribe [fsm sub] @@ -64,6 +64,27 @@ (fn [] (atom.swap! fsm.subscribers (fn [subs key] (tset subs key nil)) sub-key)))) +(fn effect-handler + [effect-map] + " + Takes a map of effect->function and returns a function that handles these + effects and cleans up on the next transition. + + These functions must return their own cleanup function + + Not required but cleans up some of the state management code + " + ;; Create a one-time atom used to store the cleanup function + (let [cleanup-ref (atom.new nil)] + ;; Return a subscriber function + (fn [{: context : prev-state : next-state : effect : action : extra}] + (log.wf "Effect handler called") + ;; Whenever a transition occurs, call the cleanup function, if set + (call-when (atom.deref cleanup-ref)) + ;; Get a new cleanup function or nil and update cleanup-ref atom + (atom.reset! cleanup-ref + (call-when (. effect-map effect) context extra))))) + (fn create-machine [states initial-state] (merge {:current-state (atom.new initial-state) @@ -77,81 +98,100 @@ (var modal-fsm nil) (fn enter-menu - [context menu] - (log.wf "XXX Enter menu %s. Current stack: %s" menu (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME - (atom.swap! context.menu-stack (fn [stack menu] (conj stack menu)) menu) + [context action extra] + (log.wf "XXX Enter menu action: %s. Current stack: %s" action (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME + (atom.swap! context.menu-stack (fn [stack menu] (conj stack menu)) extra) {:current-state :menu - :context {:history [] - :menu :main-menu} + :context (merge context {:menu-stack context.menu-stack + :current-menu :main}) :effect :modal-opened}) (fn up-menu - [context menu] - "Go up a menu in the stack. If we are the last menu, then we must fire an - event to :leave" + [context action extra] + "Go up a menu in the stack." (log.wf "XXX Up menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME - ; TODO: Unbind keys from this menu - (let [stack (atom.deref (atom.swap! context.menu-stack (fn [stack] (butlast stack))))] - (when (= (length stack) 0) (signal modal-fsm :leave)))) + ; Pop the menu off the stack + (atom.swap! context.menu-stack (fn [stack] (butlast stack))) + ; Calculate new state transition + (let [stack (atom.deref context.menu-stack) + depth (length stack) + target-state (if (= 0 depth) :idle :menu) + target-effect (if (= :idle target-state) :modal-closed :modal-opened) + new-menu (last stack)] + {:current-state target-state + :context (merge context {:menu-stack context.menu-stack + :current-menu new-menu}) + :effect target-effect}) ) (fn leave-menu - [context] - (log.wf "XXX Leave menu") ;; DELETEME + [context action extra] (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME - ; TODO: Unbind keys from this menu - (atom.swap! context.menu-stack (fn [_ menu] [])) - ) - -(fn modal-action - ; 'extra' would be the key hit, or name of the action, so we know which - ; submenu to enter, for example. A menu with 4 options would bind each to a - ; function calling (signal :enter), but each with their own 'extra'. Maybe the - ; key hit or the menu itself - [context old-state new-state action extra] - (log.wf "XXX Got action :%s with extra %s while in :%s, transitioning to :%s" action extra old-state new-state) ;; DELETEME - (if (= old-state :idle) - (if (= action :leave) nil - (= action :open) (enter-menu context :main)) - (= old-state :menu) - (if (= action :leave) (leave-menu context) - (= action :back) (up-menu context extra) - ; TODO: Which menu? Does enter-menu figure it out or do we? - (= action :select) (enter-menu context extra)))) - + {:current-state :idle + :context {:menu-stack context.menu-stack + :menu :main-menu} + :effect :modal-closed}) (local modal-states {:states {:idle {:leave :idle - :open (fn [context action extra] - {:current-state :menu - :context {:history [] - :menu :main-menu} - :effect :modal-opened})} + :open enter-menu} :menu {:leave leave-menu :back up-menu :select enter-menu}} - :context {:modal {:modal nil - :stop-func nil} + :context { ; TODO: This would be filled based on config - :menu-hierarchy nil + :menu-hierarchy {:a {} + :b {} + :c {}} + :current-menu nil :menu-stack (atom.new [])}}) -; This creates an atom for current-state and context -; TODO: We could require the initiali state me a key in the states map +; TODO: We could require the initial state be a key in the states map ; TODO: If we preserve the initial context we can maybe fsm.reset, thoug that's ; hard to do safely since it only restores state and context, not the state of ; hammerspoon itself, e.g. keys bindings, that have been messed with with all ; the signal handlers. +(fn modal-opened-menu-handler + [context extra] + (log.wf "Modal opened menu handler called") + (alert (string.format "MENU %s" extra)) + ;; Return a cleanup func + (fn [] (log.wf "Modal opened menu handler CLEANUP called"))) + +(fn modal-opened-key-handler + [context extra] + (log.wf "Modal opened key handler called") + ; TODO: Make this consider keys relative to its position in the hierarchy + (if (. context :menu-hierarchy extra) + (log.wf "Key in hierarchy") + (log.wf "Key NOT in hierarchy")) + ;; Return a cleanup func + (fn [] (log.wf "Modal opened key handler CLEANUP called"))) + +; Create FSM (set modal-fsm (create-machine modal-states :idle)) -(local unsub-display (subscribe modal-fsm (fn [] (alert "MENU HERE")))) -(local unsub-bind (subscribe modal-fsm (fn [] (log.wf "Binding keys...")))) + +; Add subscribers +(local unsub-menu-sub + (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-menu-handler}))) +(local unsub-key-sub + (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-key-handler}))) (log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME -;; (unsub) ;; DELETEME -;; (log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME -; Debuging bindings -(hs.hotkey.bind [:cmd] :s (fn [] (log.wf "XXX Current stack: %s" (hs.inspect (atom.deref modal-fsm.context.menu-stack))))) ;; DELETEME +; Debuging bindings. Call it in config.fnl so it's not trampled +(fn bind [] + (hs.hotkey.bind [:alt :cmd :ctrl] :v + (fn [] + (log.wf "XXX Current stack: %s" + (hs.inspect (atom.deref modal-fsm.context.menu-stack))))) + (hs.hotkey.bind [:cmd] :o (fn [] (signal modal-fsm :open :main))) + (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) + (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) + (hs.hotkey.bind [:cmd] :a (fn [] (signal modal-fsm :select :a))) + (hs.hotkey.bind [:cmd] :b (fn [] (signal modal-fsm :select :b))) + (hs.hotkey.bind [:cmd] :c (fn [] (signal modal-fsm :select :c)))) {: signal + : bind : modal-fsm ;; DELETEME : subscribe :new create-machine} From 8d786583366c980eddb1c2123274e0ac75e3034a Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 19 Sep 2021 15:10:43 -0400 Subject: [PATCH 05/48] Keep handlers from touching atoms --- lib/new-statemachine.fnl | 103 ++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 4b0bfed..f0f3a3f 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -9,44 +9,49 @@ (local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) -;; +;; Finite state machine ;; Schema -;; { :current-state ; An atom keyword -;; ; States table: A map of state names to a map of actions to functions -;; ; These functions must return a map containing the new state keyword, the -;; ; effect, and a new context -;; :states {:state1 {} -;; :state2 {} -;; :state3 {:leave :state2 -;; :enter :state3}}}} -;; :transitions} ; takes in fsm & event -;; :context ; an atom that tracks extra data e.g. current app, history, etc. -;; +;; {:current-state ; An atom keyword +;; ; States table: A map of state names to a map of actions to functions +;; ; These functions must return a map containing the new state keyword, the +;; ; effect, and a new context +;; :states {:state1 {} +;; :state2 {} +;; :state3 {:leave :state2 +;; :enter :state3}}}} +;; :transitions} ; takes in fsm & event +;; :context + + (fn set-current-state [fsm state] (atom.swap! fsm.current-state (fn [_ state] state) state)) +(fn set-context + [fsm context] + (atom.swap! fsm.context (fn [_ context] context) context)) + (fn signal [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call the all subscribers with the old state, new state, action, and extra" (let [current-state (atom.deref fsm.current-state) - _ (log.wf "XXX Current state %s" current-state) ;; DELETEME + _ (log.wf "XXX Current state: %s" current-state) ;; DELETEME ; TODO: Better name? This is the map that contains old, new, effect, etc. ; TODO: Handle a signal with no handler - transition ((. fsm.states current-state action) fsm.context action extra) - _ (log.wf "XXX received transition info %s" (hs.inspect transition)) ;; DELETEME + transition ((. fsm.states current-state action) (atom.deref fsm.context) action extra) + ;; _ (log.wf "XXX received transition info:\n%s" (hs.inspect transition)) ;; DELETEME next-state transition.current-state + _ (log.wf "XXX next state: %s" next-state) ;; DELETEME new-context transition.context - _ (log.wf "XXX next state %s" next-state) ;; DELETEME + ;; _ (log.wf "XXX new context: %s" (hs.inspect new-context)) ;; DELETEME effect transition.effect] ; If next-state is nil, error: Means the action is not expected in this state (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" current-state next-state action extra effect) ;; DELETEME (set-current-state fsm next-state) - ; TODO: Should we let this callback decide on the new state? But there - ; can be multiple listeners - ; TODO: Provide whole FSM or just context? + (set-context fsm new-context) + ; Call all subscribers (each [_ sub (pairs (atom.deref fsm.subscribers))] (sub {:context new-context :prev-state current-state :next-state next-state :effect effect :extra extra})))) @@ -68,17 +73,16 @@ [effect-map] " Takes a map of effect->function and returns a function that handles these - effects and cleans up on the next transition. + effects by calling the mapped-to function, and then calls that function's + return value (a cleanup function) and calls it on the next transition. - These functions must return their own cleanup function - - Not required but cleans up some of the state management code + These functions must return their own cleanup function or nil. " ;; Create a one-time atom used to store the cleanup function (let [cleanup-ref (atom.new nil)] ;; Return a subscriber function (fn [{: context : prev-state : next-state : effect : action : extra}] - (log.wf "Effect handler called") + (log.wf "Effect handler called") ;; DELETEME ;; Whenever a transition occurs, call the cleanup function, if set (call-when (atom.deref cleanup-ref)) ;; Get a new cleanup function or nil and update cleanup-ref atom @@ -87,11 +91,12 @@ (fn create-machine [states initial-state] - (merge {:current-state (atom.new initial-state) - :context (atom.new states.context) - ; TODO: Use something less naive for subscribers - :subscribers (atom.new {})} - states)) + {:current-state (atom.new initial-state) + :context (atom.new states.context) + :states states.states + ; TODO: Use something less naive for subscribers + :subscribers (atom.new {})}) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Example ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -99,36 +104,33 @@ (var modal-fsm nil) (fn enter-menu [context action extra] - (log.wf "XXX Enter menu action: %s. Current stack: %s" action (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME - (atom.swap! context.menu-stack (fn [stack menu] (conj stack menu)) extra) + (log.wf "XXX Enter menu action: %s. Current stack: %s" action (hs.inspect context.menu-stack)) ;; DELETEME {:current-state :menu - :context (merge context {:menu-stack context.menu-stack + :context (merge context {:menu-stack (conj context.menu-stack extra) :current-menu :main}) :effect :modal-opened}) (fn up-menu [context action extra] "Go up a menu in the stack." - (log.wf "XXX Up menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME - ; Pop the menu off the stack - (atom.swap! context.menu-stack (fn [stack] (butlast stack))) - ; Calculate new state transition - (let [stack (atom.deref context.menu-stack) + (log.wf "XXX Up menu. Current stack: %s" (hs.inspect context.menu-stack)) ;; DELETEME + ; Pop the menu off the stack & calculate new state transition + (let [stack (butlast context.menu-stack) depth (length stack) target-state (if (= 0 depth) :idle :menu) target-effect (if (= :idle target-state) :modal-closed :modal-opened) new-menu (last stack)] {:current-state target-state - :context (merge context {:menu-stack context.menu-stack + :context (merge context {:menu-stack stack :current-menu new-menu}) :effect target-effect}) ) (fn leave-menu [context action extra] - (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect (atom.deref context.menu-stack))) ;; DELETEME + (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect context.menu-stack)) ;; DELETEME {:current-state :idle - :context {:menu-stack context.menu-stack - :menu :main-menu} + :context (merge context {:menu-stack context.menu-stack + :menu :main-menu}) :effect :modal-closed}) (local modal-states @@ -143,10 +145,10 @@ :b {} :c {}} :current-menu nil - :menu-stack (atom.new [])}}) + :menu-stack []}}) ; TODO: We could require the initial state be a key in the states map -; TODO: If we preserve the initial context we can maybe fsm.reset, thoug that's +; TODO: If we preserve the initial context we can maybe fsm.reset, though that's ; hard to do safely since it only restores state and context, not the state of ; hammerspoon itself, e.g. keys bindings, that have been messed with with all ; the signal handlers. @@ -157,6 +159,13 @@ ;; Return a cleanup func (fn [] (log.wf "Modal opened menu handler CLEANUP called"))) +(fn modal-closed-menu-handler + [context extra] + (log.wf "Modal closed menu handler called") + (alert (string.format "MENU %s" extra)) + ;; Return a cleanup func + (fn [] (log.wf "Modal closed menu handler CLEANUP called"))) + (fn modal-opened-key-handler [context extra] (log.wf "Modal opened key handler called") @@ -172,9 +181,11 @@ ; Add subscribers (local unsub-menu-sub - (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-menu-handler}))) + (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-menu-handler + :modal-closed modal-closed-menu-handler}))) (local unsub-key-sub (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-key-handler}))) +(log.wf "FSM: %s" (hs.inspect modal-fsm)) ;; DELETEME (log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME ; Debuging bindings. Call it in config.fnl so it's not trampled @@ -182,7 +193,7 @@ (hs.hotkey.bind [:alt :cmd :ctrl] :v (fn [] (log.wf "XXX Current stack: %s" - (hs.inspect (atom.deref modal-fsm.context.menu-stack))))) + (hs.inspect (. (atom.deref modal-fsm.context) :menu-stack))))) (hs.hotkey.bind [:cmd] :o (fn [] (signal modal-fsm :open :main))) (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) @@ -191,7 +202,7 @@ (hs.hotkey.bind [:cmd] :c (fn [] (signal modal-fsm :select :c)))) {: signal - : bind + : bind ;; DELETEME : modal-fsm ;; DELETEME : subscribe :new create-machine} From 71975baa32dc1b1e57ee7dd80b3e8c12c8fa50ba Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 09:31:13 -0400 Subject: [PATCH 06/48] Cleanup a bit --- lib/new-statemachine.fnl | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index f0f3a3f..db8c52e 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -11,16 +11,18 @@ ;; Finite state machine ;; Schema -;; {:current-state ; An atom keyword +;; { +;; ; An atom keyword +;; :current-state :state1 ;; ; States table: A map of state names to a map of actions to functions ;; ; These functions must return a map containing the new state keyword, the ;; ; effect, and a new context ;; :states {:state1 {} ;; :state2 {} -;; :state3 {:leave :state2 -;; :enter :state3}}}} -;; :transitions} ; takes in fsm & event -;; :context +;; :state3 {:leave state3-leave +;; :exit state3-exit}} +;; ; An atom table +;; :context {}} (fn set-current-state @@ -34,12 +36,14 @@ (fn signal [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call - the all subscribers with the old state, new state, action, and extra" + all subscribers with the old state, new state, action, and extra" (let [current-state (atom.deref fsm.current-state) _ (log.wf "XXX Current state: %s" current-state) ;; DELETEME + context (atom.deref fsm.context) + tx-fn (. fsm.states current-state action) ; TODO: Better name? This is the map that contains old, new, effect, etc. ; TODO: Handle a signal with no handler - transition ((. fsm.states current-state action) (atom.deref fsm.context) action extra) + transition (tx-fn context action extra) ;; _ (log.wf "XXX received transition info:\n%s" (hs.inspect transition)) ;; DELETEME next-state transition.current-state _ (log.wf "XXX next state: %s" next-state) ;; DELETEME @@ -89,6 +93,11 @@ (atom.reset! cleanup-ref (call-when (. effect-map effect) context extra))))) +; TODO: We could require the initial state be a key in the states map +; TODO: If we preserve the initial context we can maybe fsm.reset, though that's +; hard to do safely since it only restores state and context, not the state of +; hammerspoon itself, e.g. keys bindings, that have been messed with with all +; the signal handlers. (fn create-machine [states initial-state] {:current-state (atom.new initial-state) @@ -140,18 +149,13 @@ :back up-menu :select enter-menu}} :context { - ; TODO: This would be filled based on config + ; This would be structured based on config in the modal module :menu-hierarchy {:a {} :b {} :c {}} :current-menu nil :menu-stack []}}) -; TODO: We could require the initial state be a key in the states map -; TODO: If we preserve the initial context we can maybe fsm.reset, though that's -; hard to do safely since it only restores state and context, not the state of -; hammerspoon itself, e.g. keys bindings, that have been messed with with all -; the signal handlers. (fn modal-opened-menu-handler [context extra] (log.wf "Modal opened menu handler called") @@ -188,7 +192,7 @@ (log.wf "FSM: %s" (hs.inspect modal-fsm)) ;; DELETEME (log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME -; Debuging bindings. Call it in config.fnl so it's not trampled +; Debuging bindings. Call it in config.fnl so the bindings aren't not trampled (fn bind [] (hs.hotkey.bind [:alt :cmd :ctrl] :v (fn [] @@ -198,11 +202,11 @@ (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) (hs.hotkey.bind [:cmd] :a (fn [] (signal modal-fsm :select :a))) - (hs.hotkey.bind [:cmd] :b (fn [] (signal modal-fsm :select :b))) - (hs.hotkey.bind [:cmd] :c (fn [] (signal modal-fsm :select :c)))) + (hs.hotkey.bind [:cmd] :r (fn [] (signal modal-fsm :select :b))) + (hs.hotkey.bind [:cmd] :s (fn [] (signal modal-fsm :select :c)))) {: signal - : bind ;; DELETEME - : modal-fsm ;; DELETEME + : bind ;; DELETEME + : modal-fsm ;; DELETEME : subscribe :new create-machine} From 942d77343d2beb6067172af3c36eb6e669a1f10e Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 16:10:53 -0400 Subject: [PATCH 07/48] create-machine takes initial state in states arg --- lib/new-statemachine.fnl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index db8c52e..6a545ea 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -93,14 +93,13 @@ (atom.reset! cleanup-ref (call-when (. effect-map effect) context extra))))) -; TODO: We could require the initial state be a key in the states map ; TODO: If we preserve the initial context we can maybe fsm.reset, though that's ; hard to do safely since it only restores state and context, not the state of ; hammerspoon itself, e.g. keys bindings, that have been messed with with all ; the signal handlers. (fn create-machine - [states initial-state] - {:current-state (atom.new initial-state) + [states] + {:current-state (atom.new states.initial-state) :context (atom.new states.context) :states states.states ; TODO: Use something less naive for subscribers @@ -143,7 +142,8 @@ :effect :modal-closed}) (local modal-states - {:states {:idle {:leave :idle + {:initial-state :idle + :states {:idle {:leave :idle :open enter-menu} :menu {:leave leave-menu :back up-menu @@ -181,7 +181,7 @@ (fn [] (log.wf "Modal opened key handler CLEANUP called"))) ; Create FSM -(set modal-fsm (create-machine modal-states :idle)) +(set modal-fsm (create-machine modal-states)) ; Add subscribers (local unsub-menu-sub From 714a51f3da4e2f6111c4c620619716bb3a240cf4 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 17:04:45 -0400 Subject: [PATCH 08/48] Combine context and state into single atom --- lib/new-statemachine.fnl | 134 ++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 6a545ea..dc4530d 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -10,55 +10,58 @@ (local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) ;; Finite state machine -;; Schema +;; Template schema ;; { -;; ; An atom keyword -;; :current-state :state1 +;; ; The state is converted to an atom in the contructor +;; :state {:current-state :state1 +;; :context {}} ;; ; States table: A map of state names to a map of actions to functions ;; ; These functions must return a map containing the new state keyword, the ;; ; effect, and a new context ;; :states {:state1 {} ;; :state2 {} ;; :state3 {:leave state3-leave -;; :exit state3-exit}} -;; ; An atom table -;; :context {}} +;; :exit state3-exit}}} - -(fn set-current-state +; TODO: Convert to method +(fn update-state [fsm state] - (atom.swap! fsm.current-state (fn [_ state] state) state)) + (atom.swap! fsm.state (fn [_ state] state) state)) -(fn set-context - [fsm context] - (atom.swap! fsm.context (fn [_ context] context) context)) +; TODO: Convert to method +(fn get-state + [fsm] + (atom.deref fsm.state)) +; TODO: Convert to method (fn signal [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call all subscribers with the old state, new state, action, and extra" - (let [current-state (atom.deref fsm.current-state) + (let [old-state (atom.deref fsm.state) + _ (log.wf "XXX old state: %s" (hs.inspect old-state)) ;; DELETEME + {: current-state : context} old-state _ (log.wf "XXX Current state: %s" current-state) ;; DELETEME - context (atom.deref fsm.context) tx-fn (. fsm.states current-state action) - ; TODO: Better name? This is the map that contains old, new, effect, etc. - ; TODO: Handle a signal with no handler - transition (tx-fn context action extra) + _ (log.wf "XXX TX func: %s" tx-fn) ;; DELETEME + ; TODO: Handle a signal with no handler for the provided action + ; TODO: Should we pass the whole state (current state and context) or just context? + transition (tx-fn old-state action extra) ;; _ (log.wf "XXX received transition info:\n%s" (hs.inspect transition)) ;; DELETEME - next-state transition.current-state - _ (log.wf "XXX next state: %s" next-state) ;; DELETEME - new-context transition.context - ;; _ (log.wf "XXX new context: %s" (hs.inspect new-context)) ;; DELETEME + new-state transition.state + _ (log.wf "XXX next state: %s" new-state.current-state) ;; DELETEME + _ (log.wf "XXX new context: %s" (hs.inspect new-state.context)) ;; DELETEME effect transition.effect] ; If next-state is nil, error: Means the action is not expected in this state - (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" current-state next-state action extra effect) ;; DELETEME + (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" current-state new-state.current-state action extra effect) ;; DELETEME - (set-current-state fsm next-state) - (set-context fsm new-context) + (update-state fsm new-state) ; Call all subscribers + (log.wf "XXX BLA %s" (hs.inspect {:prev-state old-state :next-state new-state : effect : extra})) (each [_ sub (pairs (atom.deref fsm.subscribers))] - (sub {:context new-context :prev-state current-state :next-state next-state :effect effect :extra extra})))) + (sub {:prev-state old-state :next-state new-state : action : effect : extra})))) +; TODO: Convert to method (fn subscribe [fsm sub] "Adds a subscriber to the provided fsm. Returns a function to unsubscribe" @@ -85,23 +88,18 @@ ;; Create a one-time atom used to store the cleanup function (let [cleanup-ref (atom.new nil)] ;; Return a subscriber function - (fn [{: context : prev-state : next-state : effect : action : extra}] + (fn [{: prev-state : next-state : action : effect : extra}] (log.wf "Effect handler called") ;; DELETEME ;; Whenever a transition occurs, call the cleanup function, if set (call-when (atom.deref cleanup-ref)) ;; Get a new cleanup function or nil and update cleanup-ref atom (atom.reset! cleanup-ref - (call-when (. effect-map effect) context extra))))) + (call-when (. effect-map effect) next-state extra))))) -; TODO: If we preserve the initial context we can maybe fsm.reset, though that's -; hard to do safely since it only restores state and context, not the state of -; hammerspoon itself, e.g. keys bindings, that have been messed with with all -; the signal handlers. (fn create-machine - [states] - {:current-state (atom.new states.initial-state) - :context (atom.new states.context) - :states states.states + [template] + {:state (atom.new {:current-state template.state.current-state :context template.state.context}) + :states template.states ; TODO: Use something less naive for subscribers :subscribers (atom.new {})}) @@ -110,71 +108,76 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (var modal-fsm nil) + +;; Transition functions (fn enter-menu - [context action extra] - (log.wf "XXX Enter menu action: %s. Current stack: %s" action (hs.inspect context.menu-stack)) ;; DELETEME - {:current-state :menu - :context (merge context {:menu-stack (conj context.menu-stack extra) - :current-menu :main}) + [state action extra] + (log.wf "XXX Enter menu action: %s. Current stack: %s" action (hs.inspect state.context.menu-stack)) ;; DELETEME + {:state {:current-state :menu + :context (merge state.context {:menu-stack (conj state.context.menu-stack extra) + :current-menu :main})} :effect :modal-opened}) (fn up-menu - [context action extra] + [state action extra] "Go up a menu in the stack." - (log.wf "XXX Up menu. Current stack: %s" (hs.inspect context.menu-stack)) ;; DELETEME + (log.wf "XXX Up menu. Current stack: %s" (hs.inspect state.context.menu-stack)) ;; DELETEME ; Pop the menu off the stack & calculate new state transition - (let [stack (butlast context.menu-stack) + (let [stack (butlast state.context.menu-stack) depth (length stack) target-state (if (= 0 depth) :idle :menu) target-effect (if (= :idle target-state) :modal-closed :modal-opened) new-menu (last stack)] - {:current-state target-state - :context (merge context {:menu-stack stack - :current-menu new-menu}) + {:state {:current-state target-state + :context (merge state.context {:menu-stack stack + :current-menu new-menu})} :effect target-effect}) ) (fn leave-menu - [context action extra] - (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect context.menu-stack)) ;; DELETEME - {:current-state :idle - :context (merge context {:menu-stack context.menu-stack - :menu :main-menu}) + [state action extra] + (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect state.context.menu-stack)) ;; DELETEME + {:state {:current-state :idle + :context (merge state.context {:menu-stack state.context.menu-stack + :menu :main-menu})} :effect :modal-closed}) +;; State machine (local modal-states - {:initial-state :idle - :states {:idle {:leave :idle - :open enter-menu} - :menu {:leave leave-menu - :back up-menu - :select enter-menu}} - :context { + {:state {:current-state :idle + :context { ; This would be structured based on config in the modal module :menu-hierarchy {:a {} :b {} :c {}} - :current-menu nil - :menu-stack []}}) + :current-menu :nil + :menu-stack []}} + :states {:idle {:leave :idle + :open enter-menu} + :menu {:leave leave-menu + :back up-menu + :select enter-menu}}}) + +;; Effect handlers (fn modal-opened-menu-handler - [context extra] + [state extra] (log.wf "Modal opened menu handler called") (alert (string.format "MENU %s" extra)) ;; Return a cleanup func (fn [] (log.wf "Modal opened menu handler CLEANUP called"))) (fn modal-closed-menu-handler - [context extra] + [state extra] (log.wf "Modal closed menu handler called") (alert (string.format "MENU %s" extra)) ;; Return a cleanup func (fn [] (log.wf "Modal closed menu handler CLEANUP called"))) (fn modal-opened-key-handler - [context extra] + [state extra] (log.wf "Modal opened key handler called") ; TODO: Make this consider keys relative to its position in the hierarchy - (if (. context :menu-hierarchy extra) + (if (. state :context :menu-hierarchy extra) (log.wf "Key in hierarchy") (log.wf "Key NOT in hierarchy")) ;; Return a cleanup func @@ -191,13 +194,14 @@ (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-key-handler}))) (log.wf "FSM: %s" (hs.inspect modal-fsm)) ;; DELETEME (log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME +(log.wf "State: %s" (hs.inspect (get-state modal-fsm))) ;; DELETEME ; Debuging bindings. Call it in config.fnl so the bindings aren't not trampled (fn bind [] (hs.hotkey.bind [:alt :cmd :ctrl] :v (fn [] (log.wf "XXX Current stack: %s" - (hs.inspect (. (atom.deref modal-fsm.context) :menu-stack))))) + (hs.inspect (. (atom.deref modal-fsm.state) :context :menu-stack))))) (hs.hotkey.bind [:cmd] :o (fn [] (signal modal-fsm :open :main))) (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) From 9a4da4325682d52cbbaec5dd9df1f41e77a59939 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 17:16:57 -0400 Subject: [PATCH 09/48] cleanup --- lib/new-statemachine.fnl | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index dc4530d..b46f0c7 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -23,6 +23,12 @@ ;; :state3 {:leave state3-leave ;; :exit state3-exit}}} +; TODO: Handle a signal with no handler for the provided action. E.g. if a state +; has a keyword instead of a function should we just create a new state from the +; old one, setting the new current-state to the key? This would allow simple +; transitions that don't change context, but still allow subscribers a chance to +; run (though the 'effect' will be nil) + ; TODO: Convert to method (fn update-state [fsm state] @@ -37,16 +43,13 @@ (fn signal [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call - all subscribers with the old state, new state, action, and extra" - (let [old-state (atom.deref fsm.state) - _ (log.wf "XXX old state: %s" (hs.inspect old-state)) ;; DELETEME - {: current-state : context} old-state + all subscribers with the previous state, new state, action, and extra" + (let [state (atom.deref fsm.state) + {: current-state : context} state _ (log.wf "XXX Current state: %s" current-state) ;; DELETEME tx-fn (. fsm.states current-state action) - _ (log.wf "XXX TX func: %s" tx-fn) ;; DELETEME - ; TODO: Handle a signal with no handler for the provided action ; TODO: Should we pass the whole state (current state and context) or just context? - transition (tx-fn old-state action extra) + transition (tx-fn state action extra) ;; _ (log.wf "XXX received transition info:\n%s" (hs.inspect transition)) ;; DELETEME new-state transition.state _ (log.wf "XXX next state: %s" new-state.current-state) ;; DELETEME @@ -57,9 +60,9 @@ (update-state fsm new-state) ; Call all subscribers - (log.wf "XXX BLA %s" (hs.inspect {:prev-state old-state :next-state new-state : effect : extra})) + (log.wf "XXX BLA %s" (hs.inspect {:prev-state state :next-state new-state : effect : extra})) (each [_ sub (pairs (atom.deref fsm.subscribers))] - (sub {:prev-state old-state :next-state new-state : action : effect : extra})))) + (sub {:prev-state state :next-state new-state : action : effect : extra})))) ; TODO: Convert to method (fn subscribe @@ -94,6 +97,7 @@ (call-when (atom.deref cleanup-ref)) ;; Get a new cleanup function or nil and update cleanup-ref atom (atom.reset! cleanup-ref + ; TODO: Should we provide everything e.g. prev-state, action, effect? (call-when (. effect-map effect) next-state extra))))) (fn create-machine From 67299d672318973a7ea06fb71310f18be68877aa Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 17:47:16 -0400 Subject: [PATCH 10/48] log error if current state has no handler for action --- lib/new-statemachine.fnl | 78 ++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index b46f0c7..d73c5d6 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -1,3 +1,4 @@ +(require-macros :lib.macros) (local atom (require :lib.atom)) (local {: butlast : call-when @@ -29,11 +30,14 @@ ; transitions that don't change context, but still allow subscribers a chance to ; run (though the 'effect' will be nil) -; TODO: Convert to method (fn update-state [fsm state] (atom.swap! fsm.state (fn [_ state] state) state)) +(fn get-transition-function + [fsm current-state action] + (. fsm.states current-state action)) + ; TODO: Convert to method (fn get-state [fsm] @@ -44,25 +48,22 @@ [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call all subscribers with the previous state, new state, action, and extra" - (let [state (atom.deref fsm.state) - {: current-state : context} state - _ (log.wf "XXX Current state: %s" current-state) ;; DELETEME - tx-fn (. fsm.states current-state action) - ; TODO: Should we pass the whole state (current state and context) or just context? - transition (tx-fn state action extra) - ;; _ (log.wf "XXX received transition info:\n%s" (hs.inspect transition)) ;; DELETEME - new-state transition.state - _ (log.wf "XXX next state: %s" new-state.current-state) ;; DELETEME - _ (log.wf "XXX new context: %s" (hs.inspect new-state.context)) ;; DELETEME - effect transition.effect] - ; If next-state is nil, error: Means the action is not expected in this state - (log.wf "XXX Signal current: :%s next: :%s action: :%s extra: %s effect: :%s" current-state new-state.current-state action extra effect) ;; DELETEME - - (update-state fsm new-state) - ; Call all subscribers - (log.wf "XXX BLA %s" (hs.inspect {:prev-state state :next-state new-state : effect : extra})) - (each [_ sub (pairs (atom.deref fsm.subscribers))] - (sub {:prev-state state :next-state new-state : action : effect : extra})))) + (let [state (get-state fsm) + {: current-state : context} state] + (if-let [tx-fn (get-transition-function fsm current-state action)] + ; TODO: Have per-fsm logger + (let [ + ; TODO: Should we pass the whole state (current state and context) or just context? + transition (tx-fn state action extra) + new-state transition.state + effect transition.effect] + ; If next-state is nil, error: Means the action is not expected in this state + + (update-state fsm new-state) + ; Call all subscribers + (each [_ sub (pairs (atom.deref fsm.subscribers))] + (sub {:prev-state state :next-state new-state : action : effect : extra}))) + (log.wf "Action %s does not have a transition function in state %s" action current-state)))) ; TODO: Convert to method (fn subscribe @@ -72,7 +73,6 @@ ; key, but doesn't allow the same function to subscribe more than once since ; its keyed by the string of the function itself. (let [sub-key (tostring sub)] - (log.wf "Adding subscriber %s" sub) ;; DELETEME (atom.swap! fsm.subscribers (fn [subs sub] (merge {sub-key sub} subs)) sub) ; Return the unsub func @@ -92,7 +92,6 @@ (let [cleanup-ref (atom.new nil)] ;; Return a subscriber function (fn [{: prev-state : next-state : action : effect : extra}] - (log.wf "Effect handler called") ;; DELETEME ;; Whenever a transition occurs, call the cleanup function, if set (call-when (atom.deref cleanup-ref)) ;; Get a new cleanup function or nil and update cleanup-ref atom @@ -116,7 +115,6 @@ ;; Transition functions (fn enter-menu [state action extra] - (log.wf "XXX Enter menu action: %s. Current stack: %s" action (hs.inspect state.context.menu-stack)) ;; DELETEME {:state {:current-state :menu :context (merge state.context {:menu-stack (conj state.context.menu-stack extra) :current-menu :main})} @@ -125,7 +123,6 @@ (fn up-menu [state action extra] "Go up a menu in the stack." - (log.wf "XXX Up menu. Current stack: %s" (hs.inspect state.context.menu-stack)) ;; DELETEME ; Pop the menu off the stack & calculate new state transition (let [stack (butlast state.context.menu-stack) depth (length stack) @@ -139,7 +136,6 @@ (fn leave-menu [state action extra] - (log.wf "XXX Leave menu. Current stack: %s" (hs.inspect state.context.menu-stack)) ;; DELETEME {:state {:current-state :idle :context (merge state.context {:menu-stack state.context.menu-stack :menu :main-menu})} @@ -196,25 +192,21 @@ :modal-closed modal-closed-menu-handler}))) (local unsub-key-sub (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-key-handler}))) -(log.wf "FSM: %s" (hs.inspect modal-fsm)) ;; DELETEME -(log.wf "Subs: %s" (hs.inspect (atom.deref modal-fsm.subscribers))) ;; DELETEME -(log.wf "State: %s" (hs.inspect (get-state modal-fsm))) ;; DELETEME - -; Debuging bindings. Call it in config.fnl so the bindings aren't not trampled -(fn bind [] - (hs.hotkey.bind [:alt :cmd :ctrl] :v - (fn [] - (log.wf "XXX Current stack: %s" - (hs.inspect (. (atom.deref modal-fsm.state) :context :menu-stack))))) - (hs.hotkey.bind [:cmd] :o (fn [] (signal modal-fsm :open :main))) - (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) - (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) - (hs.hotkey.bind [:cmd] :a (fn [] (signal modal-fsm :select :a))) - (hs.hotkey.bind [:cmd] :r (fn [] (signal modal-fsm :select :b))) - (hs.hotkey.bind [:cmd] :s (fn [] (signal modal-fsm :select :c)))) + +; Debuging bindings. Call it in config.fnl so the bindings aren't not trampled ;; DELETEME +(fn bind [] ;; DELETEME + (hs.hotkey.bind [:alt :cmd :ctrl] :v ;; DELETEME + (fn [] ;; DELETEME + (log.wf "XXX Current stack: %s" ;; DELETEME + (hs.inspect (. (atom.deref modal-fsm.state) :context :menu-stack))))) ;; DELETEME + (hs.hotkey.bind [:cmd] :o (fn [] (signal modal-fsm :open :main))) ;; DELETEME + (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) ;; DELETEME + (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) ;; DELETEME + (hs.hotkey.bind [:cmd] :a (fn [] (signal modal-fsm :select :a))) ;; DELETEME + (hs.hotkey.bind [:cmd] :r (fn [] (signal modal-fsm :select :b))) ;; DELETEME + (hs.hotkey.bind [:cmd] :s (fn [] (signal modal-fsm :select :c)))) ;; DELETEME {: signal - : bind ;; DELETEME - : modal-fsm ;; DELETEME + : bind ;; DELETEME : subscribe :new create-machine} From 2b96c821cf60759788b725d9ab5c4d3b40c00367 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 18:03:40 -0400 Subject: [PATCH 11/48] Add methods to fsm --- lib/new-statemachine.fnl | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index d73c5d6..4cfcdf0 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -38,12 +38,10 @@ [fsm current-state action] (. fsm.states current-state action)) -; TODO: Convert to method (fn get-state [fsm] (atom.deref fsm.state)) -; TODO: Convert to method (fn signal [fsm action extra] "Based on the action and the fsm's current-state, set the new state and call @@ -65,7 +63,6 @@ (sub {:prev-state state :next-state new-state : action : effect : extra}))) (log.wf "Action %s does not have a transition function in state %s" action current-state)))) -; TODO: Convert to method (fn subscribe [fsm sub] "Adds a subscriber to the provided fsm. Returns a function to unsubscribe" @@ -101,10 +98,15 @@ (fn create-machine [template] - {:state (atom.new {:current-state template.state.current-state :context template.state.context}) - :states template.states - ; TODO: Use something less naive for subscribers - :subscribers (atom.new {})}) + (let [fsm {:state (atom.new {:current-state template.state.current-state :context template.state.context}) + :states template.states + ; TODO: Use something less naive for subscribers + :subscribers (atom.new {})}] + ; Add methods + (tset fsm :get-state (partial get-state fsm)) + (tset fsm :signal (partial signal fsm)) + (tset fsm :subscribe (partial subscribe fsm)) + fsm)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Example From 820d81847b9ccc392aa73e4b98a05cf92ec31c4e Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sat, 25 Sep 2021 18:09:57 -0400 Subject: [PATCH 12/48] Let caller provider a logger tag --- lib/new-statemachine.fnl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 4cfcdf0..3c3ad25 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -49,7 +49,6 @@ (let [state (get-state fsm) {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] - ; TODO: Have per-fsm logger (let [ ; TODO: Should we pass the whole state (current state and context) or just context? transition (tx-fn state action extra) @@ -61,7 +60,7 @@ ; Call all subscribers (each [_ sub (pairs (atom.deref fsm.subscribers))] (sub {:prev-state state :next-state new-state : action : effect : extra}))) - (log.wf "Action %s does not have a transition function in state %s" action current-state)))) + (if fsm.log (fsm.log.wf "Action :%s does not have a transition function in state :%s" action current-state))))) (fn subscribe [fsm sub] @@ -101,7 +100,8 @@ (let [fsm {:state (atom.new {:current-state template.state.current-state :context template.state.context}) :states template.states ; TODO: Use something less naive for subscribers - :subscribers (atom.new {})}] + :subscribers (atom.new {}) + :log (if template.log (hs.logger.new template.log "info"))}] ; Add methods (tset fsm :get-state (partial get-state fsm)) (tset fsm :signal (partial signal fsm)) @@ -157,7 +157,8 @@ :open enter-menu} :menu {:leave leave-menu :back up-menu - :select enter-menu}}}) + :select enter-menu}} + :log "modal FSM"}) ;; Effect handlers From 25cf8bb307c3d366f0f078879ab17e906542c050 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 08:44:26 -0400 Subject: [PATCH 13/48] Make signal return boolean success --- lib/new-statemachine.fnl | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 3c3ad25..abac84b 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -59,8 +59,13 @@ (update-state fsm new-state) ; Call all subscribers (each [_ sub (pairs (atom.deref fsm.subscribers))] - (sub {:prev-state state :next-state new-state : action : effect : extra}))) - (if fsm.log (fsm.log.wf "Action :%s does not have a transition function in state :%s" action current-state))))) + (sub {:prev-state state :next-state new-state : action : effect : extra})) + true) + (do + (if fsm.log + (fsm.log.wf "Action :%s does not have a transition function in state :%s" + action current-state)) + false)))) (fn subscribe [fsm sub] From 907fc0c9b1207512e3fdeb26a541d68b5a4a494c Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 09:11:33 -0400 Subject: [PATCH 14/48] Fix unsub function --- lib/new-statemachine.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index abac84b..37e82ed 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -78,7 +78,7 @@ (merge {sub-key sub} subs)) sub) ; Return the unsub func (fn [] - (atom.swap! fsm.subscribers (fn [subs key] (tset subs key nil)) sub-key)))) + (atom.swap! fsm.subscribers (fn [subs key] (tset subs key nil) subs) sub-key)))) (fn effect-handler [effect-map] From 278c626949e77d15c6f363bd9e763a653a542fcf Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 09:25:05 -0400 Subject: [PATCH 15/48] Export effect-handler --- lib/new-statemachine.fnl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 37e82ed..cdb0ce0 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -214,7 +214,8 @@ (hs.hotkey.bind [:cmd] :r (fn [] (signal modal-fsm :select :b))) ;; DELETEME (hs.hotkey.bind [:cmd] :s (fn [] (signal modal-fsm :select :c)))) ;; DELETEME -{: signal +{: effect-handler + : signal : bind ;; DELETEME : subscribe :new create-machine} From 284eb436f0a59091735cf65df885741a53c43af6 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 09:12:12 -0400 Subject: [PATCH 16/48] Add tests --- lib/testing/assert.fnl | 4 ++ test/new-statemachine-test.fnl | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 test/new-statemachine-test.fnl diff --git a/lib/testing/assert.fnl b/lib/testing/assert.fnl index becbf5f..3bd7478 100644 --- a/lib/testing/assert.fnl +++ b/lib/testing/assert.fnl @@ -4,6 +4,10 @@ [actual expected message] (assert (= actual expected) (.. message " instead got " (hs.inspect actual)))) +(fn exports.not-eq? + [first second message] + (assert (not= first second) (.. message " instead both were " (hs.inspect first)))) + (fn exports.ok? [actual message] (assert (= (not (not actual)) true) (.. message " instead got " (hs.inspect actual)))) diff --git a/test/new-statemachine-test.fnl b/test/new-statemachine-test.fnl new file mode 100644 index 0000000..f4a55c8 --- /dev/null +++ b/test/new-statemachine-test.fnl @@ -0,0 +1,113 @@ +(local is (require :lib.testing.assert)) +(local statemachine (require :lib.new-statemachine)) +(local atom (require :lib.atom)) + +(fn make-fsm + [] + (statemachine.new + ;; States that the machine can be in mapped to their actions and transitions + {:state {:current-state :closed + :context {:i 0 + :event nil}} + + :states {:closed {:toggle (fn closed->opened + [state action extra] + {:state {:current-state :opened + :context {:i (+ state.context.i 1)}} + :effect :opening})} + :opened {:toggle (fn opened->closed + [state action extra] + {:state {:current-state :closed + :context {:i (+ state.context.i 1)}} + :effect :closing})}}})) + +(describe + "State Machine" + (fn [] + + (it "Should create a new fsm in the closed state" + (fn [] + (let [fsm (make-fsm)] + (is.eq? (. (atom.deref fsm.state) :current-state) :closed "Initial state was not closed")))) + + (it "Should include some methods" + (fn [] + (let [fsm (make-fsm)] + (is.eq? (type fsm.get-state) :function "No get-state method") + (is.eq? (type fsm.signal) :function "No get-state method") + (is.eq? (type fsm.subscribe) :function "No get-state method")))) + + (it "Should transition to opened on toggle action" + (fn [] + (let [fsm (make-fsm)] + (is.eq? (fsm.signal :toggle) true "Dispatch did not return true for handled event") + (is.eq? (. (atom.deref fsm.state) :current-state) :opened "State did not transition to opened")))) + + (it "Should transition from closed -> opened -> closed" + (fn [] + (let [fsm (make-fsm)] + (fsm.signal :toggle) + (fsm.signal :toggle) + (is.eq? (. (atom.deref fsm.state) :current-state) :closed "State did not transition back to closed") + (is.eq? (. (atom.deref fsm.state) :context :i) 2 "context.i should be 2 from 2 transitions")))) + + (it "Should not explode when dispatching an unhandled event" + (fn [] + (let [fsm (make-fsm)] + (is.eq? (fsm.signal :fail nil) false "The FSM exploded from dispatching a :fail event")))) + + (it "Subscribers should be called on events" + (fn [] + (let [fsm (make-fsm) + i (atom.new 0)] + (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1))))) + (fsm.signal :toggle) + (is.eq? (atom.deref i) 1 "The subscriber was not called")))) + + (it "Subscribers should be provided old and new context, action, effect, and extra" + (fn [] + (let [fsm (make-fsm) + _old (atom.new nil) + _new (atom.new nil) + _action (atom.new nil) + _effect (atom.new nil) + _extra (atom.new nil)] + (fsm.subscribe (fn [{: prev-state : next-state : action : effect : extra}] + (atom.swap! _old (fn [_ nv] nv) prev-state) + (atom.swap! _new (fn [_ nv] nv) next-state) + (atom.swap! _action (fn [_ nv] nv) action) + (atom.swap! _effect (fn [_ nv] nv) effect) + (atom.swap! _extra (fn [_ nv] nv) extra))) + (fsm.signal :toggle :extra) + (is.not-eq? (. (atom.deref _old) :context :i) + (. (atom.deref _new) :context :i) "Subscriber did not get old and new state") + (is.eq? (atom.deref _action) :toggle "Subscriber did not get correct action") + (is.eq? (atom.deref _effect) :opening "Subscriber did not get correct effect") + (is.eq? (atom.deref _extra) :extra "Subscriber did not get correct extra")))) + + (it "Subscribers should be able to unsubscribe" + (fn [] + (let [fsm (make-fsm)] + (let [i (atom.new 0) + unsub (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1)))))] + (fsm.signal :toggle) + (unsub) + (fsm.signal :toggle) + (is.eq? (atom.deref i) 1 "The subscriber was called after unsubscribing"))))) + + (it "Effect handler should maintain cleanup function" + (fn [] + (let [fsm (make-fsm) + effect-state (atom.new :unused) + unsub (fsm.subscribe (statemachine.effect-handler + {:opening (fn [] + (atom.swap! effect-state (fn [_ nv] nv) :opened) + ; Returned cleanup func + (fn [] + (atom.swap! effect-state (fn [_ nv] nv) :cleaned) + ))}))] + (fsm.signal :toggle) + (is.eq? (atom.deref effect-state) :opened "Effect handler should have been called") + (fsm.signal :toggle) + (is.eq? (atom.deref effect-state) :cleaned "Cleanup function should have been called") + ))))) From 775bb8dec7c04aa8e6a90764a9fddf1b7093b579 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 09:31:37 -0400 Subject: [PATCH 17/48] Cleanup tests slightly --- test/new-statemachine-test.fnl | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/new-statemachine-test.fnl b/test/new-statemachine-test.fnl index f4a55c8..699873e 100644 --- a/test/new-statemachine-test.fnl +++ b/test/new-statemachine-test.fnl @@ -34,8 +34,8 @@ (fn [] (let [fsm (make-fsm)] (is.eq? (type fsm.get-state) :function "No get-state method") - (is.eq? (type fsm.signal) :function "No get-state method") - (is.eq? (type fsm.subscribe) :function "No get-state method")))) + (is.eq? (type fsm.signal) :function "No signal method ") + (is.eq? (type fsm.subscribe) :function "No subscribe method")))) (it "Should transition to opened on toggle action" (fn [] @@ -99,13 +99,15 @@ (fn [] (let [fsm (make-fsm) effect-state (atom.new :unused) - unsub (fsm.subscribe (statemachine.effect-handler - {:opening (fn [] - (atom.swap! effect-state (fn [_ nv] nv) :opened) - ; Returned cleanup func - (fn [] - (atom.swap! effect-state (fn [_ nv] nv) :cleaned) - ))}))] + effect-handler (statemachine.effect-handler + {:opening (fn [] + (atom.swap! effect-state + (fn [_ nv] nv) :opened) + ; Returned cleanup func + (fn [] + (atom.swap! effect-state + (fn [_ nv] nv) :cleaned)))}) + unsub (fsm.subscribe effect-handler)] (fsm.signal :toggle) (is.eq? (atom.deref effect-state) :opened "Effect handler should have been called") (fsm.signal :toggle) From ff9b5ec7ef9bf00853a14f8b3ed64230e0f18ae4 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 12:12:08 -0400 Subject: [PATCH 18/48] Add new-modal using new-statemachine --- core.fnl | 1 + lib/modal.fnl | 7 + lib/new-modal.fnl | 390 +++++++++++++++++++++++++++++++++++++++ lib/new-statemachine.fnl | 12 +- 4 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 lib/new-modal.fnl diff --git a/core.fnl b/core.fnl index f588700..4d2cd3b 100644 --- a/core.fnl +++ b/core.fnl @@ -219,6 +219,7 @@ Returns nil. This function causes side-effects. :apps :lib.bind :lib.modal + :lib.new-modal :lib.apps]) (defadvice get-config-impl diff --git a/lib/modal.fnl b/lib/modal.fnl index 28707c8..0f57482 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -538,4 +538,11 @@ switching menus in one place which is then powered by config.fnl. {:init init + : modal-alert ;; DELETEME: Temporary for PR + : timeout ;; DELETEME: Temporary for PR + : by-key ;; DELETEME: Temporary for PR + : activate-modal ;; DELETEME: Temporary for PR + : deactivate-modal ;; DELETEME: Temporary for PR + : previous-modal ;; DELETEME: Temporary for PR + : proxy-app-action ;; DELETEME: Temporary for PR :activate-modal activate-modal} diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl new file mode 100644 index 0000000..c16395f --- /dev/null +++ b/lib/new-modal.fnl @@ -0,0 +1,390 @@ +" +Displays the menu modals, sub-menus, and application-specific modals if set +in config.fnl. + +We define a state machine, which uses our local states to determine states, and +transitions. Then we can signal events that may transition between specific +states defined in the table. + +Allows us to create the machinery for displaying, entering, exiting, and +switching menus in one place which is then powered by config.fnl. +" +(local atom (require :lib.atom)) +(local statemachine (require :lib.new-statemachine)) +(local om (require :lib.modal)) ;; DELETEME: For PR +(local apps (require :lib.apps)) +(local {: butlast + : call-when + : concat + : conj + : find + : filter + : has-some? + : identity + : join + : map + : merge + : noop} + (require :lib.functional)) +(local {:align-columns align-columns} + (require :lib.text)) +(local {:action->fn action->fn + :bind-keys bind-keys} + (require :lib.bind)) +(local lifecycle (require :lib.lifecycle)) + +(local log (hs.logger.new "\tmodal.fnl\t" "debug")) +(var fsm nil) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Set Key Bindings +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn create-action-trigger + [{:action action :repeatable repeatable :timeout timeout}] + " + Creates a function to dispatch an action associated with a menu item defined + by config.fnl. + Takes a table defining the following: + + action :: function | string - Either a string like \"module:function-name\" + or a fennel function to call. + repeatable :: bool | nil - If this action is repeatable like jumping between + windows where we might wish to jump 2 windows + left and it wouldn't want to re-enter the jump menu + timeout :: bool | nil - If a timeout should be started. Defaults to true when + repeatable is true. + + Returns a function to execute the action-fn async. + " + (let [action-fn (action->fn action)] + (fn [] + (if (and repeatable (~= timeout false)) + (om.start-modal-timeout) + (not repeatable) + (om.deactivate-modal)) + ;; Delay the action-fn ever so slightly + ;; to speed up the closing of the menu + ;; This makes the UI feel slightly snappier + (hs.timer.doAfter 0.01 action-fn)))) + + +(fn create-menu-trigger + [{:key key}] + " + Takes a config menu option and returns a function to enter that submenu when + action is activated. + Returns a function to activate submenu. + " + (fn [] + (om.activate-modal key))) + + +(fn select-trigger + [item] + " + Transform a menu item into an action to either call a function or enter a + submenu. + Takes a menu item from config.fnl + Returns a function to perform the action associated with menu item. + " + (if (and item.action (= item.action :previous)) + om.previous-modal + item.action + (create-action-trigger item) + item.items + (create-menu-trigger item) + (fn [] + (log.w "No trigger could be found for item: " + (hs.inspect item))))) + + +(fn bind-item + [item] + " + Create a bindspec to map modal menu items to actions and submenus. + Takes a menu item + Returns a table to create a hs key binding. + " + {:mods (or item.mods []) + :key item.key + :action (select-trigger item)}) + + +(fn bind-menu-keys + [items] + " + Binds all actions and submenu items within a menu to VenueBook. + Takes a list of modal menu items. + Returns a function to remove menu key bindings for easy cleanup. + " + (-> items + (->> (filter (fn [item] + (or item.action + item.items))) + (map bind-item)) + (concat [{:key :ESCAPE + :action om.deactivate-modal} + {:mods [:ctrl] + :key "[" + :action om.deactivate-modal}]) + (bind-keys))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Display Modals +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn show-modal-menu + [{:menu menu}] + " + Main API to display a modal and run side-effects + - Display the modal alert + Takes current modal state from our modal statemachine + Returns the function to cleanup everything it sets up + " + (lifecycle.enter-menu menu) + (om.modal-alert menu) + (let [unbind-keys (bind-menu-keys menu.items)] + (fn [] + (hs.alert.closeAll 0) + (unbind-keys) + (lifecycle.exit-menu menu) + ))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; State Transition Functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +(fn idle->active + [state action extra] + " + Transition our modal statemachine from the idle state to active where a menu + modal is displayed to the user. + Takes the current modal state table plus the key of the menu if submenu + Kicks off an effect to display the modal or local app menu + Returns updated modal state machine state table. + " + (let [config state.context.config + app-menu (apps.get-app) + menu (if (and app-menu (has-some? app-menu.items)) + app-menu + config)] + (log.wf "TRANSITION: idle->active app-menu %s menu %s config %s" app-menu menu config) ;; DELETEME + {:state {:current-state :active + :context (merge state.context {:menu menu + :history (if state.history + (conj history menu) + [menu])})} + :effect :show-modal-menu})) + + +(fn active->idle + [state action extra] + " + Transition our modal state machine from the active, open state to idle. + Takes the current modal state table. + Kicks off an effect to close the modal, stop the timeout, and unbind keys + Returns updated modal state machine state table. + " + (log.wf "TRANSITION: active->idle") ;; DELETEME + {:state {:current-state :idle + :context (merge state.context {:menu :nil + :history []})} + :effect :close-modal-menu}) + + +(fn active->enter-app + [state action extra] + " + Transition our modal state machine the main menu to an app menu + Takes the current modal state table and the app menu table. + Displays updated modal menu if the current menu is different than the previous + menu otherwise results in no operation + Returns new modal state + " + (log.wf "TRANSITION: active->enter-app") ;; DELETEME + (let [{:config config + :menu prev-menu} state.context + app-menu (apps.get-app) + menu (if (and app-menu (has-some? app-menu.items)) + app-menu + config)] + (if (= menu.key prev-menu.key) + ; nil transition object means keep all state + nil + {:state {:current-state :submenu + :context (merge state.context {:menu menu})} + :effect :open-submenu}))) + + +(fn active->leave-app + [state action extra] + " + Transition to the regular menu when user removes focus (blurs) another app. + If the leave event was fired for the app we are already in, do nothing. + Takes the current modal state table. + Returns new updated modal state if we are leaving the current app. + " + (log.wf "TRANSITION: active->leave-app") ;; DELETEME + (let [{:config config + :menu prev-menu} state.context] + (if (= prev-menu.key config.key) + nil + (idle->active state action extra)))) + + +(fn active->submenu + [state action menu-key] + " + Enter a submenu like entering into the Window menu from the default main menu. + Takes the current menu state table and the submenu key as 'extra'. + Returns updated menu state + " + (let [{:config config + :menu prev-menu} state.context + menu (if menu-key + (find (om.by-key menu-key) prev-menu.items) + config)] + (log.wf "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME + {:state {:current-state :submenu + :context (merge state.context {:menu menu})} + :effect :open-submenu})) + +(fn active->timeout + [state action extra] + " + Transition from active to idle, but this transition only fires when the + timeout occurs. The timeout is only started after firing a repeatable action. + For instance if you enter window > jump east you may want to jump again + without having to bring up the modal and enter the window submenu. We wait for + more modal keypresses until the timeout triggers which will deactivate the + modal. + Takes the current modal state table. + Returns a the old state with a :stop-timeout added + " + (log.wf "TRANSITION: active->timeout") ;; DELETEME + {:state {:current-state :submenu + :context + (merge state.context {:stop-timeout (om.timeout om.deactivate-modal)})} + :effect :open-submenu}) + +(fn submenu->previous + [state action extra] + " + Transition to the previous submenu. Like if you went into the window menu + and wanted to go back to the main menu. + Takes the modal state table. + Returns a partial modal state table update. + Dynamically calls another transition depending on history. + " + (let [{:config config + :history hist + :menu menu} state.context + prev-menu (. hist (- (length hist) 1))] + (log.wf "TRANSITION: submenu->previous") ;; DELETEME + (if prev-menu + {:state {:current-state :submenu + :context (merge state.context {:menu prev-menu + :history (butlast hist)})} + :effect :open-submenu} + (idle->active state)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Finite State Machine States +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +;; State machine states table. Maps states to actions to transition functions. +;; These transition functions return transition objects that contain the new +;; state key and context. +(local states + {:idle {:activate idle->active + :enter-app noop + :leave-app noop} + :active {:deactivate active->idle + :activate active->submenu + :start-timeout active->timeout + :enter-app active->enter-app + :leave-app active->leave-app} + :submenu {:deactivate active->idle + :activate active->submenu + :previous submenu->previous + :start-timeout active->timeout + :enter-app noop + :leave-app noop}}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Watchers, Dispatchers, & Logging +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +(fn start-logger + [fsm] + " + Start logging the status of the modal state machine. + Takes our finite state machine. + Returns nil + Creates a watcher of our state atom to log state changes reactively. + " + (atom.add-watch + fsm.state :log-state + (fn log-state + [state] + (log.df "state is now: %s" state.current-state) ;; DELETEME + (when state.context.history + (log.df (hs.inspect (map #(. $1 :title) state.context.history))))))) + +; TODO: Bind show-modal-menu direct +; TODO: Do we only need one effect? +(local modal-effect + (statemachine.effect-handler + {:show-modal-menu (fn [state extra] + (log.wf "Effect: show modal") ;; DELETEME + (show-modal-menu state.context)) + :open-submenu (fn [state extra] + (log.wf "Effect: Open submenu with extra %s" extra) ;; DELETEME + (show-modal-menu state.context))})) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn init + [config] + " + Initialize the modal state machine responsible for displaying modal alerts + to the user to trigger actions defined by their config.fnl. + Takes the config.fnl table. + Causes side effects to start the state machine, show the modal, and logging. + Returns a function to unsubscribe from the app state machine. + " + (let [initial-context {:config config + :history [] + :menu :nil} + template {:state {:current-state :idle + :context initial-context} + :states states + :log "modal"} + unsubscribe (apps.subscribe om.proxy-app-action)] + (set fsm (statemachine.new template)) + (tset fsm :dispatch fsm.signal) ; DELETEME: TEMP: Monkey patch dispatch to show dispatchers haven't changed + (fsm.subscribe modal-effect) + (start-logger fsm) + (fn cleanup [] + (unsubscribe)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +{:init init + :activate-modal om.activate-modal} diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index cdb0ce0..0adf0ab 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -51,10 +51,12 @@ (if-let [tx-fn (get-transition-function fsm current-state action)] (let [ ; TODO: Should we pass the whole state (current state and context) or just context? + _ (log.wf "XXX SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) - new-state transition.state - effect transition.effect] - ; If next-state is nil, error: Means the action is not expected in this state + ;; _ (log.wf "XXX SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME + ; TODO: Noop return nothing. Just keep the state the same, then + new-state (if transition transition.state state) + effect (if transition transition.effect nil)] (update-state fsm new-state) ; Call all subscribers @@ -149,7 +151,7 @@ :effect :modal-closed}) ;; State machine -(local modal-states +(local modal-states-template {:state {:current-state :idle :context { ; This would be structured based on config in the modal module @@ -192,7 +194,7 @@ (fn [] (log.wf "Modal opened key handler CLEANUP called"))) ; Create FSM -(set modal-fsm (create-machine modal-states)) +(set modal-fsm (create-machine modal-states-template)) ; Add subscribers (local unsub-menu-sub From 78e79fbc3cfb9db03db77f173a2b99bc5e928d39 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 12:48:25 -0400 Subject: [PATCH 19/48] Remove example and add big doc string --- lib/new-statemachine.fnl | 151 +++++++++++---------------------------- 1 file changed, 41 insertions(+), 110 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 0adf0ab..3a0bf8b 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -1,3 +1,43 @@ +" +Provides the mechanism to generate a finite state machine. + +A finite state machine defines states and some way to transition between states. + +The 'new' function takes a template, which is a table with the following schema: +{ + :state {:current-state :state1 + :context {}} + :states {:state1 {} + :state2 {} + :state3 {:leave transition-fn-leave + :exit transition-fn-exit}}} + +* The CONTEXT is any table that can be updated by TRANSITION FUNCTIONS. This + allows the client to track their own state. +* The STATES table is a map from ACTIONS to TRANSITION FUNCTIONS. +* These functions must return a TRANSITION OBJECT containing the new + :state and the :effect. +* The :state contains a (potentially changed) :current-state and a new :context, + which is updated in the state machine. +* Functions can subscribe to all signals, and are provided a TRANSITION RECORD, + which contains: + * :prev-state + * :next-state + * :action + * :effect that was kicked off from the transition function +* The subscribe method returns a function that can be called to unsubscribe. + +Additionally, we provide a helper function `effect-handler`, which is a +higher-order function that returns a function suitable to be provided to +subscribe. It takes a map of EFFECTs to handler functions. These handler +functions should return their own cleanup. The effect-handler will automatically +call this cleanup function after the next transition. For example, if you want +to bind keys when a certain effect is kicked off, write a function that binds +the keys and returns an unbind function. The unbind function will be called on +the next transition. +" + + (require-macros :lib.macros) (local atom (require :lib.atom)) (local {: butlast @@ -12,17 +52,6 @@ ;; Finite state machine ;; Template schema -;; { -;; ; The state is converted to an atom in the contructor -;; :state {:current-state :state1 -;; :context {}} -;; ; States table: A map of state names to a map of actions to functions -;; ; These functions must return a map containing the new state keyword, the -;; ; effect, and a new context -;; :states {:state1 {} -;; :state2 {} -;; :state3 {:leave state3-leave -;; :exit state3-exit}}} ; TODO: Handle a signal with no handler for the provided action. E.g. if a state ; has a keyword instead of a function should we just create a new state from the @@ -116,108 +145,10 @@ fsm)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Example +;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(var modal-fsm nil) - -;; Transition functions -(fn enter-menu - [state action extra] - {:state {:current-state :menu - :context (merge state.context {:menu-stack (conj state.context.menu-stack extra) - :current-menu :main})} - :effect :modal-opened}) - -(fn up-menu - [state action extra] - "Go up a menu in the stack." - ; Pop the menu off the stack & calculate new state transition - (let [stack (butlast state.context.menu-stack) - depth (length stack) - target-state (if (= 0 depth) :idle :menu) - target-effect (if (= :idle target-state) :modal-closed :modal-opened) - new-menu (last stack)] - {:state {:current-state target-state - :context (merge state.context {:menu-stack stack - :current-menu new-menu})} - :effect target-effect}) ) - -(fn leave-menu - [state action extra] - {:state {:current-state :idle - :context (merge state.context {:menu-stack state.context.menu-stack - :menu :main-menu})} - :effect :modal-closed}) - -;; State machine -(local modal-states-template - {:state {:current-state :idle - :context { - ; This would be structured based on config in the modal module - :menu-hierarchy {:a {} - :b {} - :c {}} - :current-menu :nil - :menu-stack []}} - :states {:idle {:leave :idle - :open enter-menu} - :menu {:leave leave-menu - :back up-menu - :select enter-menu}} - :log "modal FSM"}) - - -;; Effect handlers -(fn modal-opened-menu-handler - [state extra] - (log.wf "Modal opened menu handler called") - (alert (string.format "MENU %s" extra)) - ;; Return a cleanup func - (fn [] (log.wf "Modal opened menu handler CLEANUP called"))) - -(fn modal-closed-menu-handler - [state extra] - (log.wf "Modal closed menu handler called") - (alert (string.format "MENU %s" extra)) - ;; Return a cleanup func - (fn [] (log.wf "Modal closed menu handler CLEANUP called"))) - -(fn modal-opened-key-handler - [state extra] - (log.wf "Modal opened key handler called") - ; TODO: Make this consider keys relative to its position in the hierarchy - (if (. state :context :menu-hierarchy extra) - (log.wf "Key in hierarchy") - (log.wf "Key NOT in hierarchy")) - ;; Return a cleanup func - (fn [] (log.wf "Modal opened key handler CLEANUP called"))) - -; Create FSM -(set modal-fsm (create-machine modal-states-template)) - -; Add subscribers -(local unsub-menu-sub - (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-menu-handler - :modal-closed modal-closed-menu-handler}))) -(local unsub-key-sub - (subscribe modal-fsm (effect-handler {:modal-opened modal-opened-key-handler}))) - -; Debuging bindings. Call it in config.fnl so the bindings aren't not trampled ;; DELETEME -(fn bind [] ;; DELETEME - (hs.hotkey.bind [:alt :cmd :ctrl] :v ;; DELETEME - (fn [] ;; DELETEME - (log.wf "XXX Current stack: %s" ;; DELETEME - (hs.inspect (. (atom.deref modal-fsm.state) :context :menu-stack))))) ;; DELETEME - (hs.hotkey.bind [:cmd] :o (fn [] (signal modal-fsm :open :main))) ;; DELETEME - (hs.hotkey.bind [:cmd] :u (fn [] (signal modal-fsm :back nil))) ;; DELETEME - (hs.hotkey.bind [:cmd] :l (fn [] (signal modal-fsm :leave nil))) ;; DELETEME - (hs.hotkey.bind [:cmd] :a (fn [] (signal modal-fsm :select :a))) ;; DELETEME - (hs.hotkey.bind [:cmd] :r (fn [] (signal modal-fsm :select :b))) ;; DELETEME - (hs.hotkey.bind [:cmd] :s (fn [] (signal modal-fsm :select :c)))) ;; DELETEME - {: effect-handler : signal - : bind ;; DELETEME : subscribe :new create-machine} From 1cc0852bc0d63126347cef46abce87af83f4e761 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 13:24:38 -0400 Subject: [PATCH 20/48] Rename timeout transition function There is no timeout state so the name makes no sense. --- lib/new-modal.fnl | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index c16395f..bca8893 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -137,20 +137,22 @@ switching menus in one place which is then powered by config.fnl. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn show-modal-menu - [{:menu menu}] + [context] " Main API to display a modal and run side-effects - Display the modal alert Takes current modal state from our modal statemachine Returns the function to cleanup everything it sets up " - (lifecycle.enter-menu menu) - (om.modal-alert menu) - (let [unbind-keys (bind-menu-keys menu.items)] + (lifecycle.enter-menu context.menu) + (om.modal-alert context.menu) + (let [unbind-keys (bind-menu-keys context.menu.items) + stop-timeout context.stop-timeout] (fn [] (hs.alert.closeAll 0) (unbind-keys) - (lifecycle.exit-menu menu) + (call-when stop-timeout) + (lifecycle.exit-menu context.menu) ))) @@ -254,7 +256,7 @@ switching menus in one place which is then powered by config.fnl. :context (merge state.context {:menu menu})} :effect :open-submenu})) -(fn active->timeout +(fn add-timeout-transition [state action extra] " Transition from active to idle, but this transition only fires when the @@ -266,8 +268,8 @@ switching menus in one place which is then powered by config.fnl. Takes the current modal state table. Returns a the old state with a :stop-timeout added " - (log.wf "TRANSITION: active->timeout") ;; DELETEME - {:state {:current-state :submenu + (log.wf "TRANSITION: add-timeout-transition") ;; DELETEME + {:state {:current-state state.context.current-state :context (merge state.context {:stop-timeout (om.timeout om.deactivate-modal)})} :effect :open-submenu}) @@ -308,13 +310,13 @@ switching menus in one place which is then powered by config.fnl. :leave-app noop} :active {:deactivate active->idle :activate active->submenu - :start-timeout active->timeout + :start-timeout add-timeout-transition :enter-app active->enter-app :leave-app active->leave-app} :submenu {:deactivate active->idle :activate active->submenu :previous submenu->previous - :start-timeout active->timeout + :start-timeout add-timeout-transition :enter-app noop :leave-app noop}}) From b167f03837ac68a7f7f68372c23f58e40944ffa0 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 26 Sep 2021 14:42:34 -0400 Subject: [PATCH 21/48] Remove more duplicated code for review --- lib/modal.fnl | 1 + lib/new-modal.fnl | 97 +---------------------------------------------- 2 files changed, 2 insertions(+), 96 deletions(-) diff --git a/lib/modal.fnl b/lib/modal.fnl index 0f57482..25d9c4f 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -545,4 +545,5 @@ switching menus in one place which is then powered by config.fnl. : deactivate-modal ;; DELETEME: Temporary for PR : previous-modal ;; DELETEME: Temporary for PR : proxy-app-action ;; DELETEME: Temporary for PR + : bind-menu-keys :activate-modal activate-modal} diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index bca8893..a8a1e1c 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -37,101 +37,6 @@ switching menus in one place which is then powered by config.fnl. (var fsm nil) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Set Key Bindings -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn create-action-trigger - [{:action action :repeatable repeatable :timeout timeout}] - " - Creates a function to dispatch an action associated with a menu item defined - by config.fnl. - Takes a table defining the following: - - action :: function | string - Either a string like \"module:function-name\" - or a fennel function to call. - repeatable :: bool | nil - If this action is repeatable like jumping between - windows where we might wish to jump 2 windows - left and it wouldn't want to re-enter the jump menu - timeout :: bool | nil - If a timeout should be started. Defaults to true when - repeatable is true. - - Returns a function to execute the action-fn async. - " - (let [action-fn (action->fn action)] - (fn [] - (if (and repeatable (~= timeout false)) - (om.start-modal-timeout) - (not repeatable) - (om.deactivate-modal)) - ;; Delay the action-fn ever so slightly - ;; to speed up the closing of the menu - ;; This makes the UI feel slightly snappier - (hs.timer.doAfter 0.01 action-fn)))) - - -(fn create-menu-trigger - [{:key key}] - " - Takes a config menu option and returns a function to enter that submenu when - action is activated. - Returns a function to activate submenu. - " - (fn [] - (om.activate-modal key))) - - -(fn select-trigger - [item] - " - Transform a menu item into an action to either call a function or enter a - submenu. - Takes a menu item from config.fnl - Returns a function to perform the action associated with menu item. - " - (if (and item.action (= item.action :previous)) - om.previous-modal - item.action - (create-action-trigger item) - item.items - (create-menu-trigger item) - (fn [] - (log.w "No trigger could be found for item: " - (hs.inspect item))))) - - -(fn bind-item - [item] - " - Create a bindspec to map modal menu items to actions and submenus. - Takes a menu item - Returns a table to create a hs key binding. - " - {:mods (or item.mods []) - :key item.key - :action (select-trigger item)}) - - -(fn bind-menu-keys - [items] - " - Binds all actions and submenu items within a menu to VenueBook. - Takes a list of modal menu items. - Returns a function to remove menu key bindings for easy cleanup. - " - (-> items - (->> (filter (fn [item] - (or item.action - item.items))) - (map bind-item)) - (concat [{:key :ESCAPE - :action om.deactivate-modal} - {:mods [:ctrl] - :key "[" - :action om.deactivate-modal}]) - (bind-keys))) - - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Display Modals ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -146,7 +51,7 @@ switching menus in one place which is then powered by config.fnl. " (lifecycle.enter-menu context.menu) (om.modal-alert context.menu) - (let [unbind-keys (bind-menu-keys context.menu.items) + (let [unbind-keys (om.bind-menu-keys context.menu.items) stop-timeout context.stop-timeout] (fn [] (hs.alert.closeAll 0) From 8bca53cca7db2bac6b8475a1c85d818911244d8f Mon Sep 17 00:00:00 2001 From: Grazfather Date: Thu, 30 Sep 2021 23:23:20 -0400 Subject: [PATCH 22/48] Duplicate funcs from statemachine into new --- lib/modal.fnl | 11 +-- lib/new-modal.fnl | 247 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 242 insertions(+), 16 deletions(-) diff --git a/lib/modal.fnl b/lib/modal.fnl index 25d9c4f..546984f 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -537,13 +537,6 @@ switching menus in one place which is then powered by config.fnl. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{:init init - : modal-alert ;; DELETEME: Temporary for PR - : timeout ;; DELETEME: Temporary for PR - : by-key ;; DELETEME: Temporary for PR - : activate-modal ;; DELETEME: Temporary for PR - : deactivate-modal ;; DELETEME: Temporary for PR - : previous-modal ;; DELETEME: Temporary for PR - : proxy-app-action ;; DELETEME: Temporary for PR +{: init : bind-menu-keys - :activate-modal activate-modal} + : activate-modal} diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index a8a1e1c..8d94fd5 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -11,7 +11,6 @@ switching menus in one place which is then powered by config.fnl. " (local atom (require :lib.atom)) (local statemachine (require :lib.new-statemachine)) -(local om (require :lib.modal)) ;; DELETEME: For PR (local apps (require :lib.apps)) (local {: butlast : call-when @@ -37,10 +36,217 @@ switching menus in one place which is then powered by config.fnl. (var fsm nil) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; General Utils +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn timeout + [f] + " + Create a pre-set timeout task that takes a function to run later. + Takes a function to call after 2 seconds. + Returns a function to destroy the timeout task. + " + (let [task (hs.timer.doAfter 2 f)] + (fn destroy-task + [] + (when task + (: task :stop) + nil)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Event Dispatchers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn activate-modal + [menu-key] + " + API to transition to the active state of our modal finite state machine + It is called by a trigger set on the outside world and provided relevant + context to determine which menu modal to activate. + Takes the name of a menu to activate or nil if it's the root menu. + menu-key refers to either a submenu key in config.fnl or an application + specific menu key. + Side effectful + " + (fsm.signal :activate menu-key)) + + +(fn deactivate-modal + [] + " + API to transition to the idle state of our modal finite state machine. + Takes no arguments. + Side effectful + " + (fsm.signal :deactivate)) + + +(fn previous-modal + [] + " + API to transition to the previous modal in our history. Useful for returning + to the main menu when in the window modal for instance. + " + (fsm.signal :previous)) + + +(fn start-modal-timeout + [] + " + API for starting a menu timeout. Some menu actions like the window navigation + actions can be repeated without having to re-enter into the Menu + Modal > Window but we don't want to be listening for key events indefinitely. + This begins a timeout that will close the modal and remove the key bindings + after a time delay specified in the timout function. + Takes no arguments. + Side effectful + " + (fsm.signal :start-timeout)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Set Key Bindings +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn create-action-trigger + [{:action action :repeatable repeatable :timeout timeout}] + " + Creates a function to dispatch an action associated with a menu item defined + by config.fnl. + Takes a table defining the following: + + action :: function | string - Either a string like \"module:function-name\" + or a fennel function to call. + repeatable :: bool | nil - If this action is repeatable like jumping between + windows where we might wish to jump 2 windows + left and it wouldn't want to re-enter the jump menu + timeout :: bool | nil - If a timeout should be started. Defaults to true when + repeatable is true. + + Returns a function to execute the action-fn async. + " + (let [action-fn (action->fn action)] + (fn [] + (if (and repeatable (~= timeout false)) + (start-modal-timeout) + (not repeatable) + (deactivate-modal)) + ;; Delay the action-fn ever so slightly + ;; to speed up the closing of the menu + ;; This makes the UI feel slightly snappier + (hs.timer.doAfter 0.01 action-fn)))) + + +(fn create-menu-trigger + [{:key key}] + " + Takes a config menu option and returns a function to enter that submenu when + action is activated. + Returns a function to activate submenu. + " + (fn [] + (activate-modal key))) + + +(fn select-trigger + [item] + " + Transform a menu item into an action to either call a function or enter a + submenu. + Takes a menu item from config.fnl + Returns a function to perform the action associated with menu item. + " + (if (and item.action (= item.action :previous)) + previous-modal + item.action + (create-action-trigger item) + item.items + (create-menu-trigger item) + (fn [] + (log.w "No trigger could be found for item: " + (hs.inspect item))))) + + +(fn bind-item + [item] + " + Create a bindspec to map modal menu items to actions and submenus. + Takes a menu item + Returns a table to create a hs key binding. + " + {:mods (or item.mods []) + :key item.key + :action (select-trigger item)}) + + +(fn bind-menu-keys + [items] + " + Binds all actions and submenu items within a menu to VenueBook. + Takes a list of modal menu items. + Returns a function to remove menu key bindings for easy cleanup. + " + (-> items + (->> (filter (fn [item] + (or item.action + item.items))) + (map bind-item)) + (concat [{:key :ESCAPE + :action deactivate-modal} + {:mods [:ctrl] + :key "[" + :action deactivate-modal}]) + (bind-keys))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Display Modals ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(local mod-chars {:cmd "CMD" + :alt "OPT" + :shift "SHFT" + :tab "TAB"}) + +(fn format-key + [item] + " + Format the key binding of a menu item to display in a modal menu to user + Takes a modal menu item + Returns a string describing the key + " + (let [mods (-?>> item.mods + (map (fn [m] (or (. mod-chars m) m))) + (join " ") + (identity))] + (.. (or mods "") + (if mods " + " "") + item.key))) + + +(fn modal-alert + [menu] + " + Display a menu modal in an hs.alert. + Takes a menu table specified in config.fnl + Opens an alert modal as a side effect + Returns nil + " + (let [items (->> menu.items + (filter (fn [item] item.title)) + (map (fn [item] + [(format-key item) (. item :title)])) + (align-columns)) + text (join "\n" items)] + (hs.alert.closeAll) + (alert text + {:textFont "Menlo" + :textSize 16 + :radius 0 + :strokeWidth 0} + 99999))) + (fn show-modal-menu [context] " @@ -50,8 +256,8 @@ switching menus in one place which is then powered by config.fnl. Returns the function to cleanup everything it sets up " (lifecycle.enter-menu context.menu) - (om.modal-alert context.menu) - (let [unbind-keys (om.bind-menu-keys context.menu.items) + (modal-alert context.menu) + (let [unbind-keys (bind-menu-keys context.menu.items) stop-timeout context.stop-timeout] (fn [] (hs.alert.closeAll 0) @@ -60,6 +266,21 @@ switching menus in one place which is then powered by config.fnl. (lifecycle.exit-menu context.menu) ))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Menus, & Config Navigation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn by-key + [target] + " + Checker function to filter menu items where key matches target + Takes a target string to look for like \"window\" + Returns true or false + " + (fn [item] + (and (= (. item :key) target) + (has-some? item.items)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; State Transition Functions @@ -154,7 +375,7 @@ switching menus in one place which is then powered by config.fnl. (let [{:config config :menu prev-menu} state.context menu (if menu-key - (find (om.by-key menu-key) prev-menu.items) + (find (by-key menu-key) prev-menu.items) config)] (log.wf "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME {:state {:current-state :submenu @@ -176,7 +397,7 @@ switching menus in one place which is then powered by config.fnl. (log.wf "TRANSITION: add-timeout-transition") ;; DELETEME {:state {:current-state state.context.current-state :context - (merge state.context {:stop-timeout (om.timeout om.deactivate-modal)})} + (merge state.context {:stop-timeout (timeout deactivate-modal)})} :effect :open-submenu}) (fn submenu->previous @@ -258,6 +479,18 @@ switching menus in one place which is then powered by config.fnl. (log.wf "Effect: Open submenu with extra %s" extra) ;; DELETEME (show-modal-menu state.context))})) +(fn proxy-app-action + [[action data]] + " + Provide a semi-public API function for other state machines to dispatch + changes to the modal menu state. Currently used by the app state machine to + tell the modal menu state machine when an app is launched, activated, + deactivated, or exited. + Executes a side-effect + Returns nil + " + (fsm.dispatch action data)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Initialization @@ -279,7 +512,7 @@ switching menus in one place which is then powered by config.fnl. :context initial-context} :states states :log "modal"} - unsubscribe (apps.subscribe om.proxy-app-action)] + unsubscribe (apps.subscribe proxy-app-action)] (set fsm (statemachine.new template)) (tset fsm :dispatch fsm.signal) ; DELETEME: TEMP: Monkey patch dispatch to show dispatchers haven't changed (fsm.subscribe modal-effect) @@ -294,4 +527,4 @@ switching menus in one place which is then powered by config.fnl. {:init init - :activate-modal om.activate-modal} + : activate-modal} From fe2840aeb3d20c240881f05c71c60b9680d8356f Mon Sep 17 00:00:00 2001 From: Grazfather Date: Wed, 6 Oct 2021 13:41:11 -0400 Subject: [PATCH 23/48] Fix timeout current-state --- lib/new-modal.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index 8d94fd5..a5893d9 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -395,7 +395,7 @@ switching menus in one place which is then powered by config.fnl. Returns a the old state with a :stop-timeout added " (log.wf "TRANSITION: add-timeout-transition") ;; DELETEME - {:state {:current-state state.context.current-state + {:state {:current-state state.current-state :context (merge state.context {:stop-timeout (timeout deactivate-modal)})} :effect :open-submenu}) From ac4199e92ddce0fc3213e032054c2b49754783d0 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Wed, 6 Oct 2021 13:43:32 -0400 Subject: [PATCH 24/48] Remove some todos --- lib/new-statemachine.fnl | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 3a0bf8b..a858f98 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -53,12 +53,6 @@ the next transition. ;; Finite state machine ;; Template schema -; TODO: Handle a signal with no handler for the provided action. E.g. if a state -; has a keyword instead of a function should we just create a new state from the -; old one, setting the new current-state to the key? This would allow simple -; transitions that don't change context, but still allow subscribers a chance to -; run (though the 'effect' will be nil) - (fn update-state [fsm state] (atom.swap! fsm.state (fn [_ state] state) state)) @@ -79,7 +73,6 @@ the next transition. {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] (let [ - ; TODO: Should we pass the whole state (current state and context) or just context? _ (log.wf "XXX SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) ;; _ (log.wf "XXX SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME @@ -128,14 +121,12 @@ the next transition. (call-when (atom.deref cleanup-ref)) ;; Get a new cleanup function or nil and update cleanup-ref atom (atom.reset! cleanup-ref - ; TODO: Should we provide everything e.g. prev-state, action, effect? (call-when (. effect-map effect) next-state extra))))) (fn create-machine [template] (let [fsm {:state (atom.new {:current-state template.state.current-state :context template.state.context}) :states template.states - ; TODO: Use something less naive for subscribers :subscribers (atom.new {}) :log (if template.log (hs.logger.new template.log "info"))}] ; Add methods From a1c01809720eb0a61477427b87b7b9c646db8871 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Wed, 6 Oct 2021 13:48:25 -0400 Subject: [PATCH 25/48] remove todo --- lib/new-statemachine.fnl | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index a858f98..3afdf67 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -76,7 +76,6 @@ the next transition. _ (log.wf "XXX SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) ;; _ (log.wf "XXX SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME - ; TODO: Noop return nothing. Just keep the state the same, then new-state (if transition transition.state state) effect (if transition transition.effect nil)] From f3be7ec164f01e1d933e1654bf43252dadece9b2 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Wed, 6 Oct 2021 14:02:59 -0400 Subject: [PATCH 26/48] Cleanup docstrings --- lib/new-statemachine.fnl | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index 3afdf67..de268ed 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -48,10 +48,8 @@ the next transition. : merge : slice} (require :lib.functional)) -(local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) - -;; Finite state machine -;; Template schema +(local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) ;; DELETEME + ;; DELETEME (fn update-state [fsm state] @@ -67,8 +65,10 @@ the next transition. (fn signal [fsm action extra] - "Based on the action and the fsm's current-state, set the new state and call - all subscribers with the previous state, new state, action, and extra" + " + Based on the action and the fsm's current-state, set the new state and call + all subscribers with the previous state, new state, action, and extra. + " (let [state (get-state fsm) {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] @@ -92,10 +92,11 @@ the next transition. (fn subscribe [fsm sub] - "Adds a subscriber to the provided fsm. Returns a function to unsubscribe" - ; Super naive: Returns a function that just removes the entry at the inserted - ; key, but doesn't allow the same function to subscribe more than once since - ; its keyed by the string of the function itself. + " + Adds a subscriber to the provided fsm. Returns a function to unsubscribe + Naive: Because each entry is keyed by the function address it doesn't allow + the same function to subscribe more than once. + " (let [sub-key (tostring sub)] (atom.swap! fsm.subscribers (fn [subs sub] (merge {sub-key sub} subs)) sub) From e3978062b2163c033c90fb719f1b054f2e26fa73 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Wed, 6 Oct 2021 13:48:17 -0400 Subject: [PATCH 27/48] wip new apps --- core.fnl | 3 +- lib/new-apps.fnl | 464 +++++++++++++++++++++++++++++++++++++++ lib/new-modal.fnl | 2 +- lib/new-statemachine.fnl | 4 +- 4 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 lib/new-apps.fnl diff --git a/core.fnl b/core.fnl index 4d2cd3b..be3548f 100644 --- a/core.fnl +++ b/core.fnl @@ -218,9 +218,8 @@ Returns nil. This function causes side-effects. :windows :apps :lib.bind - :lib.modal :lib.new-modal - :lib.apps]) + :lib.new-apps]) (defadvice get-config-impl [] diff --git a/lib/new-apps.fnl b/lib/new-apps.fnl new file mode 100644 index 0000000..5d06552 --- /dev/null +++ b/lib/new-apps.fnl @@ -0,0 +1,464 @@ +" +Creates a finite state machine to handle app-specific events. +A user may specify app-specific key bindings or menu items in their config.fnl + +Uses a state machine to better organize logic for entering apps we have config +for, versus switching between apps, versus exiting apps, versus activating apps. + +This module works mechanically similar to lib/modal.fnl. +" +(local atom (require :lib.atom)) +(local statemachine (require :lib.new-statemachine)) +(local os (require :os)) +(local {: call-when + : find + : merge + : noop + : tap} + (require :lib.functional)) +(local {:action->fn action->fn + :bind-keys bind-keys} + (require :lib.bind)) +(local lifecycle (require :lib.lifecycle)) + + +(local log (hs.logger.new "new-apps.fnl" "debug")) + +(local actions (atom.new nil)) +;; Create a dynamic var to hold an accessible instance of our finite state +;; machine for apps. +(var fsm nil) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Utils +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn gen-key + [] + " + Generate a unique, random, base64 encoded string 7 chars long. + Takes no arguments. + Side effectful. + Returns unique 7 char, randomized string. + " + (var nums "") + (for [i 1 7] + (set nums (.. nums (math.random 0 9)))) + (string.sub (hs.base64.encode nums) 1 7)) + +(fn emit + [action data] + " + When an action occurs in our state machine we want to broadcast it for systems + like modals to transition. + Takes action name and data to transition another finite state machine. + Side-effect: Updates the actions atom. + Returns nil. + " + (atom.swap! actions (fn [] [action data]))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Action signalers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn enter + [app-name] + " + Action to focus or activate an app. App must have either menu options + or key bindings defined in config.fnl. + + Takes the name of the app we entered. + Transitions to the entered finite-state-machine state. + Returns nil. + " + (fsm.signal :enter-app app-name)) + +(fn leave + [app-name] + " + The user has deactivated\blurred an app we have config defined. + Takes the name of the app the user deactivated. + Transition the state machine to idle from active app state. + Returns nil. + " + (fsm.signal :leave-app app-name)) + +(fn launch + [app-name] + " + The user launched an app we have config defined for. + Takes name of the app launched. + Calls the launch lifecycle method defined for an app in config.fnl + Returns nil. + " + (fsm.signal :launch-app app-name)) + +(fn close + [app-name] + " + The user closed an app we have config defined for. + Takes name of the app closed. + Calls the exit lifecycle method defined for an app in config.fnl + Returns nil. + " + (fsm.signal :close-app app-name)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Set Key Bindings +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn bind-app-keys + [items] + " + Bind config.fnl app keys to actions + Takes a list of local app bindings + Returns a function to call without arguments to remove bindings. + " + (bind-keys items)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Apps Navigation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn by-key + [target] + " + Checker to search for app definitions to find the app with a key property + that matches the target. + Takes a target key string + Returns a predicate that takes an app menu table and returns true if + app.key == target + " + (fn [app] + (= app.key target))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; State Transitions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn ->enter + [state action app-name] + " + Transition the app state machine from the general, shared key bindings to an + app we have local keybindings for. + Kicks off an effect to bind app-specific keys. + Takes the current app state machine state table + Returns update modal state machine state table. + " + (let [{: apps + : app} state.context + next-app (find (by-key app-name) apps)] + (log.wf "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME + (when next-app + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :enter-app-effect}))) + + +(fn in-app->leave + [state action app-name] + " + Transition the app state machine from an app the user was using with local + keybindings to another app that may or may not have local keybindings. + Because a 'enter (new) app' action is fired before a 'leave (old) app', we + know that this will be called AFTER the enter transition has updated the + state, so we should not update the state. + Takes the current app state machine state table, + Kicks off an effect to run leave-app effects and unbind the old app's keys + Returns the old state. + " + (log.wf "TRANSITION: in-app->leave app %s" app-name) ;; DELETEME + {:state state + :effect :leave-app-effect}) + +(fn launch-app + [state action app-name] + " + Using the state machine we also react to launching apps by calling the :launch + lifecycle method on apps defined in a user's config.fnl. This way they can run + hammerspoon functions when an app is opened like say resizing emacs on launch. + Takes the current app state machine state table. + Kicks off an effect to bind app-specific keys & fire launch app lifecycle + Returns a new state. + " + (let [{: apps + : app} state + next-app (find (by-key app-name) apps)] + (log.wf "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME + (when next-app + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :launch-app-effect}))) + +(fn ->close + [state action app-name] + " + Using the state machine we also react to launching apps by calling the :close + lifecycle method on apps defined in a user's config.fnl. This way they can run + hammerspoon functions when an app is closed. For instance re-enabling vim mode + when an app is closed that was incompatible + Takes the current app state machine state table + Kicks off an effect to bind app-specific keys + Returns the old state + " + (log.wf "TRANSITION: ->close app app-name %s" app-name) ;; DELETEME + {:state state + :effect :close-app-effect}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Finite State Machine States +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +" +State machine transition definitions +Defines the two states our app state machine can be in: +1. General, non-specific app where no table defined in config.fnl exists +2. In a specific app where a table is defined to customize local keys, + modal menu items, or lifecycle methods to trigger other hammerspoon functions +Maps each state to a table of actions mapped to handlers responsible for +returning the next state the statemachine is in. +" + +(local states + {:general-app {:enter-app ->enter + :leave-app noop + :launch-app launch-app + :close-app ->close} + :in-app {:enter-app ->enter + :leave-app in-app->leave + :launch-app launch-app + :close-app ->close}}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Watchers, Dispatchers, & Logging +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +" +Assign some simple keywords for each hs.application.watcher event type. +" +(local app-events + {hs.application.watcher.activated :activated + hs.application.watcher.deactivated :deactivated + hs.application.watcher.hidden :hidden + hs.application.watcher.launched :launched + hs.application.watcher.launching :launching + hs.application.watcher.terminated :terminated + hs.application.watcher.unhidden :unhidden}) + + +(fn watch-apps + [app-name event app] + " + Hammerspoon application watcher callback + Looks up the event type based on our keyword mappings and dispatches the + corresponding action against the state machine to manage side-effects and + update their state. + + Takes the name of the app, the hs.application.watcher event-type, an the + hs.application.instance that triggered the event. + Returns nil. Relies on side-effects. + " + (let [event-type (. app-events event)] + (log.wf "Got watch-apps event %s" event-type) ;; DELETEME + (if (= event-type :activated) + (enter app-name) + (= event-type :deactivated) + (leave app-name) + (= event-type :launched) + (launch app-name) + (= event-type :terminated) + (close app-name)))) + +(fn active-app-name + [] + " + Internal API function to return the name of the frontmost app + Returns the name of the app if there is a frontmost app or nil. + " + (let [app (hs.application.frontmostApplication)] + (if app + (: app :name) + nil))) + +(fn start-logger + [fsm] + " + Debugging handler to add a watcher to the apps finite-state-machine + state atom to log changes over time. + " + (atom.add-watch + fsm.state :log-state + (fn log-state + [state] + (log.df "app is now: %s" (and state.context.app state.context.app.key))))) + +(fn proxy-actions + [fsm] + " + Internal API function to emit app-specific state machine events and transitions to + other state machines. Like telling our modal state machine the user has + entered into emacs so display the emacs-specific menu modal. + Takes the apps finite state machine instance. + Performs a side-effect to watch the finite-state-machine and log each action + to a list of actions other FSMs can subscribe to like a stream. + Returns nil. + " + (atom.add-watch fsm.state :actions + (fn action-watcher + [state] + (emit state.action state.app)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; API Methods +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn get-app + [] + " + Public API method to get the user's config table for the current app defined + in their config.fnl. + Takes no arguments. + Returns the current app config table or nil if no config was defined for the + current app. + " + (when fsm + (let [state (atom.deref fsm.state)] + state.app))) + +(fn subscribe + [f] + " + Public API to subscribe to the stream atom of app specific actions. + Allows the menu modal FSM to subscribe to app actions to know when to switch + to an app specific menu or revert back to default main menu. + Takes a function to call on each action update. + Returns a function to remove the subscription to actions stream. + " + (let [key (gen-key)] + (atom.add-watch actions key f) + (fn unsubscribe + [] + (atom.remove-watch actions key)))) + +(fn enter-app-effect + [context] + " + Bind keys and lifecycle for the new current app. + Return a cleanup function to cleanup these bindings. + " + (lifecycle.activate-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.wf "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.wf "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys)))) + +(fn launch-app-effect + [context] + " + Bind keys and lifecycle for the next current app. + Return a cleanup function to cleanup these bindings. + " + (lifecycle.launch-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.wf "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.wf "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys)))) + +(fn my-effect-handler + [effect-map] + " + Takes a map of effect->function and returns a function that handles these + effects by calling the mapped-to function, and then calls that function's + return value (a cleanup function) and calls it on the next transition. + + Unlike the fsm's effect-handler, these are app-aware and only call the cleanup + function for that particular app. + + These functions must return their own cleanup function or nil. + " + ;; Create a one-time atom used to store the cleanup function map + (let [cleanup-ref (atom.new {})] + ;; Return a subscriber function + (fn [{: prev-state : next-state : action : effect : extra}] + ;; Whenever a transition occurs, call the cleanup function for that + ;; particular app, if set + (log.wf "EFFECTS HANDLER for effect %s on app %s" effect extra) ;; DELETEME + ;; Call the cleanup function for this app if it's set + (call-when (. (atom.deref cleanup-ref) extra)) + (let [cleanup-map (atom.deref cleanup-ref) + effect-func (. effect-map effect)] + (log.wf "Cleanup map: %s" (hs.inspect cleanup-map)) ;; DELETEME + ;; Update the cleanup entry for this app with a new func or nil + (atom.reset! cleanup-ref + (merge cleanup-map + {extra (call-when effect-func next-state extra)})))))) + +(local apps-effect + (my-effect-handler + {:enter-app-effect (fn [state extra] + (log.wf "EFFECT: enter-app") ;; DELETEME + (enter-app-effect state.context)) + :leave-app-effect (fn [state extra] + (log.wf "EFFECT: leave-app") ;; DELETEME + (lifecycle.deactivate-app state.context.app) + nil) + :launch-app-effect (fn [state extra] + (log.wf "EFFECT: launch-app") ;; DELETEME + (launch-app-effect state.context)) + :close-app-effect (fn [state extra] + (log.wf "EFFECT: close-app") ;; DELETEME + (lifecycle.close-app state.context.app) + nil)})) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn init + [config] + " + Initialize apps finite-state-machine and create hs.application.watcher + instance to listen for app specific events. + Takes the current config.fnl table + Returns a function to cleanup the hs.application.watcher. + " + (let [active-app (active-app-name) + initial-context {:apps config.apps + :app nil} + template {:state {:current-state :general-app + :context initial-context} + :states states + :log "apps"} + app-watcher (hs.application.watcher.new watch-apps)] + (set fsm (statemachine.new template)) + (fsm.subscribe apps-effect) + (start-logger fsm) + (proxy-actions fsm) + (enter active-app) + (: app-watcher :start) + (fn cleanup [] + (: app-watcher :stop)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +{: init + : get-app + : subscribe} diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index a5893d9..092947c 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -11,7 +11,7 @@ switching menus in one place which is then powered by config.fnl. " (local atom (require :lib.atom)) (local statemachine (require :lib.new-statemachine)) -(local apps (require :lib.apps)) +(local apps (require :lib.new-apps)) (local {: butlast : call-when : concat diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index de268ed..ff31084 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -73,9 +73,9 @@ the next transition. {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] (let [ - _ (log.wf "XXX SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME + _ (log.wf "SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) - ;; _ (log.wf "XXX SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME + ;; _ (log.wf "SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME new-state (if transition transition.state state) effect (if transition transition.effect nil)] From 6eed9961141b948a6e88cb5ae726e14e3eda236c Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 8 Oct 2021 07:50:04 -0400 Subject: [PATCH 28/48] apps: Fix getting current app to display app-menu --- lib/new-apps.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/new-apps.fnl b/lib/new-apps.fnl index 5d06552..476357c 100644 --- a/lib/new-apps.fnl +++ b/lib/new-apps.fnl @@ -334,7 +334,7 @@ Assign some simple keywords for each hs.application.watcher event type. " (when fsm (let [state (atom.deref fsm.state)] - state.app))) + state.context.app))) (fn subscribe [f] From 31bf99ecfb6f1c66bc11a60693cc6acc943d44b1 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 8 Oct 2021 08:52:08 -0400 Subject: [PATCH 29/48] apps: Update app even when new app isn't in config We want to do this so that once we leave an app the state of the machine has :app set to nil, otherwise the modal menu will continue to display items from the previous app. Because we are still firing an effect, we need to guard around the effect handlers, doing nothing when we have an enter or launch effect but no app. --- lib/new-apps.fnl | 52 ++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/new-apps.fnl b/lib/new-apps.fnl index 476357c..9a7f3ca 100644 --- a/lib/new-apps.fnl +++ b/lib/new-apps.fnl @@ -152,13 +152,12 @@ This module works mechanically similar to lib/modal.fnl. (let [{: apps : app} state.context next-app (find (by-key app-name) apps)] - (log.wf "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME - (when next-app - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :enter-app-effect}))) + (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :enter-app-effect})) (fn in-app->leave @@ -190,13 +189,12 @@ This module works mechanically similar to lib/modal.fnl. (let [{: apps : app} state next-app (find (by-key app-name) apps)] - (log.wf "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME - (when next-app - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :launch-app-effect}))) + (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :launch-app-effect})) (fn ->close [state action app-name] @@ -357,12 +355,13 @@ Assign some simple keywords for each hs.application.watcher event type. Bind keys and lifecycle for the new current app. Return a cleanup function to cleanup these bindings. " - (lifecycle.activate-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (log.wf "Returning cleanup for %s" context.app.key) ;; DELETEME - (fn [] - (log.wf "Calling unbind keys for %s" context.app.key) ;; DELETEME - (unbind-keys)))) + (when context.app + (lifecycle.activate-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys))))) (fn launch-app-effect [context] @@ -370,12 +369,13 @@ Assign some simple keywords for each hs.application.watcher event type. Bind keys and lifecycle for the next current app. Return a cleanup function to cleanup these bindings. " - (lifecycle.launch-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (log.wf "Returning cleanup for %s" context.app.key) ;; DELETEME - (fn [] - (log.wf "Calling unbind keys for %s" context.app.key) ;; DELETEME - (unbind-keys)))) + (when context.app + (lifecycle.launch-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys))))) (fn my-effect-handler [effect-map] From 3ec63bb73be3e0aa80ccb37adf48e13b711bcdc7 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 8 Oct 2021 08:54:09 -0400 Subject: [PATCH 30/48] Log debug not warn --- lib/new-apps.fnl | 18 +++++++++--------- lib/new-modal.fnl | 18 +++++++++--------- lib/new-statemachine.fnl | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/new-apps.fnl b/lib/new-apps.fnl index 9a7f3ca..8b9dde8 100644 --- a/lib/new-apps.fnl +++ b/lib/new-apps.fnl @@ -172,7 +172,7 @@ This module works mechanically similar to lib/modal.fnl. Kicks off an effect to run leave-app effects and unbind the old app's keys Returns the old state. " - (log.wf "TRANSITION: in-app->leave app %s" app-name) ;; DELETEME + (log.df "TRANSITION: in-app->leave app %s" app-name) ;; DELETEME {:state state :effect :leave-app-effect}) @@ -207,7 +207,7 @@ This module works mechanically similar to lib/modal.fnl. Kicks off an effect to bind app-specific keys Returns the old state " - (log.wf "TRANSITION: ->close app app-name %s" app-name) ;; DELETEME + (log.df "TRANSITION: ->close app app-name %s" app-name) ;; DELETEME {:state state :effect :close-app-effect}) @@ -267,7 +267,7 @@ Assign some simple keywords for each hs.application.watcher event type. Returns nil. Relies on side-effects. " (let [event-type (. app-events event)] - (log.wf "Got watch-apps event %s" event-type) ;; DELETEME + (log.df "Got watch-apps event %s" event-type) ;; DELETEME (if (= event-type :activated) (enter app-name) (= event-type :deactivated) @@ -395,12 +395,12 @@ Assign some simple keywords for each hs.application.watcher event type. (fn [{: prev-state : next-state : action : effect : extra}] ;; Whenever a transition occurs, call the cleanup function for that ;; particular app, if set - (log.wf "EFFECTS HANDLER for effect %s on app %s" effect extra) ;; DELETEME + (log.df "EFFECTS HANDLER for effect %s on app %s" effect extra) ;; DELETEME ;; Call the cleanup function for this app if it's set (call-when (. (atom.deref cleanup-ref) extra)) (let [cleanup-map (atom.deref cleanup-ref) effect-func (. effect-map effect)] - (log.wf "Cleanup map: %s" (hs.inspect cleanup-map)) ;; DELETEME + (log.df "Cleanup map: %s" (hs.inspect cleanup-map)) ;; DELETEME ;; Update the cleanup entry for this app with a new func or nil (atom.reset! cleanup-ref (merge cleanup-map @@ -409,17 +409,17 @@ Assign some simple keywords for each hs.application.watcher event type. (local apps-effect (my-effect-handler {:enter-app-effect (fn [state extra] - (log.wf "EFFECT: enter-app") ;; DELETEME + (log.df "EFFECT: enter-app") ;; DELETEME (enter-app-effect state.context)) :leave-app-effect (fn [state extra] - (log.wf "EFFECT: leave-app") ;; DELETEME + (log.df "EFFECT: leave-app") ;; DELETEME (lifecycle.deactivate-app state.context.app) nil) :launch-app-effect (fn [state extra] - (log.wf "EFFECT: launch-app") ;; DELETEME + (log.df "EFFECT: launch-app") ;; DELETEME (launch-app-effect state.context)) :close-app-effect (fn [state extra] - (log.wf "EFFECT: close-app") ;; DELETEME + (log.df "EFFECT: close-app") ;; DELETEME (lifecycle.close-app state.context.app) nil)})) diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index 092947c..c787ef9 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -301,7 +301,7 @@ switching menus in one place which is then powered by config.fnl. menu (if (and app-menu (has-some? app-menu.items)) app-menu config)] - (log.wf "TRANSITION: idle->active app-menu %s menu %s config %s" app-menu menu config) ;; DELETEME + (log.df "TRANSITION: idle->active app-menu %s menu %s config %s" (and app-menu app-menu.key) menu config) ;; DELETEME {:state {:current-state :active :context (merge state.context {:menu menu :history (if state.history @@ -318,7 +318,7 @@ switching menus in one place which is then powered by config.fnl. Kicks off an effect to close the modal, stop the timeout, and unbind keys Returns updated modal state machine state table. " - (log.wf "TRANSITION: active->idle") ;; DELETEME + (log.df "TRANSITION: active->idle") ;; DELETEME {:state {:current-state :idle :context (merge state.context {:menu :nil :history []})} @@ -334,7 +334,7 @@ switching menus in one place which is then powered by config.fnl. menu otherwise results in no operation Returns new modal state " - (log.wf "TRANSITION: active->enter-app") ;; DELETEME + (log.df "TRANSITION: active->enter-app action %s extra %s" action extra) ;; DELETEME (let [{:config config :menu prev-menu} state.context app-menu (apps.get-app) @@ -357,7 +357,7 @@ switching menus in one place which is then powered by config.fnl. Takes the current modal state table. Returns new updated modal state if we are leaving the current app. " - (log.wf "TRANSITION: active->leave-app") ;; DELETEME + (log.df "TRANSITION: active->leave-app") ;; DELETEME (let [{:config config :menu prev-menu} state.context] (if (= prev-menu.key config.key) @@ -377,7 +377,7 @@ switching menus in one place which is then powered by config.fnl. menu (if menu-key (find (by-key menu-key) prev-menu.items) config)] - (log.wf "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME + (log.df "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME {:state {:current-state :submenu :context (merge state.context {:menu menu})} :effect :open-submenu})) @@ -394,7 +394,7 @@ switching menus in one place which is then powered by config.fnl. Takes the current modal state table. Returns a the old state with a :stop-timeout added " - (log.wf "TRANSITION: add-timeout-transition") ;; DELETEME + (log.df "TRANSITION: add-timeout-transition") ;; DELETEME {:state {:current-state state.current-state :context (merge state.context {:stop-timeout (timeout deactivate-modal)})} @@ -413,7 +413,7 @@ switching menus in one place which is then powered by config.fnl. :history hist :menu menu} state.context prev-menu (. hist (- (length hist) 1))] - (log.wf "TRANSITION: submenu->previous") ;; DELETEME + (log.df "TRANSITION: submenu->previous") ;; DELETEME (if prev-menu {:state {:current-state :submenu :context (merge state.context {:menu prev-menu @@ -473,10 +473,10 @@ switching menus in one place which is then powered by config.fnl. (local modal-effect (statemachine.effect-handler {:show-modal-menu (fn [state extra] - (log.wf "Effect: show modal") ;; DELETEME + (log.df "Effect: show modal") ;; DELETEME (show-modal-menu state.context)) :open-submenu (fn [state extra] - (log.wf "Effect: Open submenu with extra %s" extra) ;; DELETEME + (log.df "Effect: Open submenu with extra %s" extra) ;; DELETEME (show-modal-menu state.context))})) (fn proxy-app-action diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index ff31084..b71fdae 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -73,9 +73,9 @@ the next transition. {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] (let [ - _ (log.wf "SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME + _ (log.df "SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) - ;; _ (log.wf "SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME + ;; _ (log.df "SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME new-state (if transition transition.state state) effect (if transition transition.effect nil)] @@ -86,7 +86,7 @@ the next transition. true) (do (if fsm.log - (fsm.log.wf "Action :%s does not have a transition function in state :%s" + (fsm.log.df "Action :%s does not have a transition function in state :%s" action current-state)) false)))) From a385ae70493473f371e9d82b8809cc2b1724aaff Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 8 Oct 2021 09:14:08 -0400 Subject: [PATCH 31/48] Get app switching in modal working --- lib/new-apps.fnl | 17 +++++++---------- lib/new-modal.fnl | 19 +++++++------------ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/lib/new-apps.fnl b/lib/new-apps.fnl index 8b9dde8..a0d1e06 100644 --- a/lib/new-apps.fnl +++ b/lib/new-apps.fnl @@ -300,21 +300,18 @@ Assign some simple keywords for each hs.application.watcher event type. [state] (log.df "app is now: %s" (and state.context.app state.context.app.key))))) -(fn proxy-actions - [fsm] +(fn action-watcher + [{: prev-state : next-state : action : effect : extra}] " Internal API function to emit app-specific state machine events and transitions to other state machines. Like telling our modal state machine the user has entered into emacs so display the emacs-specific menu modal. - Takes the apps finite state machine instance. - Performs a side-effect to watch the finite-state-machine and log each action - to a list of actions other FSMs can subscribe to like a stream. + Subscribes to the apps state machine. + Takes a transition record from the FSM. Returns nil. " - (atom.add-watch fsm.state :actions - (fn action-watcher - [state] - (emit state.action state.app)))) + (log.df "PROXY action %s effect %s extra %s app %s" action effect extra (and next-state.context.app next-state.context.app.key)) ; DELETEME + (emit action next-state.context.app)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -447,7 +444,7 @@ Assign some simple keywords for each hs.application.watcher event type. (set fsm (statemachine.new template)) (fsm.subscribe apps-effect) (start-logger fsm) - (proxy-actions fsm) + (fsm.subscribe action-watcher) (enter active-app) (: app-watcher :start) (fn cleanup [] diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index c787ef9..e12854f 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -22,8 +22,7 @@ switching menus in one place which is then powered by config.fnl. : identity : join : map - : merge - : noop} + : merge} (require :lib.functional)) (local {:align-columns align-columns} (require :lib.text)) @@ -325,7 +324,7 @@ switching menus in one place which is then powered by config.fnl. :effect :close-modal-menu}) -(fn active->enter-app +(fn ->enter-app [state action extra] " Transition our modal state machine the main menu to an app menu @@ -334,7 +333,7 @@ switching menus in one place which is then powered by config.fnl. menu otherwise results in no operation Returns new modal state " - (log.df "TRANSITION: active->enter-app action %s extra %s" action extra) ;; DELETEME + (log.df "TRANSITION: ->enter-app action %s extra %s" action extra) ;; DELETEME (let [{:config config :menu prev-menu} state.context app-menu (apps.get-app) @@ -431,20 +430,16 @@ switching menus in one place which is then powered by config.fnl. ;; These transition functions return transition objects that contain the new ;; state key and context. (local states - {:idle {:activate idle->active - :enter-app noop - :leave-app noop} + {:idle {:activate idle->active} :active {:deactivate active->idle :activate active->submenu :start-timeout add-timeout-transition - :enter-app active->enter-app - :leave-app active->leave-app} + :enter-app ->enter-app} :submenu {:deactivate active->idle :activate active->submenu :previous submenu->previous :start-timeout add-timeout-transition - :enter-app noop - :leave-app noop}}) + :enter-app ->enter-app}}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -526,5 +521,5 @@ switching menus in one place which is then powered by config.fnl. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{:init init +{: init : activate-modal} From 8b6c388901c00eab53502727edc6d55bf44df2f2 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 8 Oct 2021 09:33:49 -0400 Subject: [PATCH 32/48] Cleanup some logging --- lib/new-modal.fnl | 3 ++- lib/new-statemachine.fnl | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl index e12854f..d8fc204 100644 --- a/lib/new-modal.fnl +++ b/lib/new-modal.fnl @@ -31,7 +31,7 @@ switching menus in one place which is then powered by config.fnl. (require :lib.bind)) (local lifecycle (require :lib.lifecycle)) -(local log (hs.logger.new "\tmodal.fnl\t" "debug")) +(local log (hs.logger.new "new-modal.fnl" "debug")) (var fsm nil) @@ -484,6 +484,7 @@ switching menus in one place which is then powered by config.fnl. Executes a side-effect Returns nil " + (log.df "PROXY FROM APPS action %s data %s" action data) ; DELETEME (fsm.dispatch action data)) diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl index b71fdae..58a34bc 100644 --- a/lib/new-statemachine.fnl +++ b/lib/new-statemachine.fnl @@ -48,7 +48,7 @@ the next transition. : merge : slice} (require :lib.functional)) -(local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) ;; DELETEME +(local log (hs.logger.new "new-statemachine.fnl" "debug")) ;; DELETEME ;; DELETEME (fn update-state From 35873d6c248fbc7dd6634478f1a66e3043e5e41c Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 12:32:42 -0400 Subject: [PATCH 33/48] Remove old modal, apps, mostly remove old statemachine --- core.fnl | 5 +- lib/apps.fnl | 302 ++++++++++--------- lib/modal.fnl | 238 +++++++-------- lib/new-apps.fnl | 461 ----------------------------- lib/new-modal.fnl | 526 --------------------------------- lib/new-statemachine.fnl | 145 --------- lib/statemachine.fnl | 240 ++++++++------- test/new-statemachine-test.fnl | 115 ------- test/statemachine-test.fnl | 117 ++++++-- 9 files changed, 498 insertions(+), 1651 deletions(-) delete mode 100644 lib/new-apps.fnl delete mode 100644 lib/new-modal.fnl delete mode 100644 lib/new-statemachine.fnl delete mode 100644 test/new-statemachine-test.fnl diff --git a/core.fnl b/core.fnl index be3548f..ea5998e 100644 --- a/core.fnl +++ b/core.fnl @@ -216,10 +216,9 @@ Returns nil. This function causes side-effects. (local modules [:lib.hyper :vim :windows - :apps :lib.bind - :lib.new-modal - :lib.new-apps]) + :lib.modal + :lib.apps]) (defadvice get-config-impl [] diff --git a/lib/apps.fnl b/lib/apps.fnl index 99ae34b..32edef5 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -12,6 +12,7 @@ This module works mechanically similar to lib/modal.fnl. (local os (require :os)) (local {: call-when : find + : merge : noop : tap} (require :lib.functional)) @@ -57,9 +58,8 @@ This module works mechanically similar to lib/modal.fnl. " (atom.swap! actions (fn [] [action data]))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Event Dispatchers +;; Action signalers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn enter @@ -72,7 +72,7 @@ This module works mechanically similar to lib/modal.fnl. Transitions to the entered finite-state-machine state. Returns nil. " - (fsm.dispatch :enter-app app-name)) + (fsm.signal :enter-app app-name)) (fn leave [app-name] @@ -82,7 +82,7 @@ This module works mechanically similar to lib/modal.fnl. Transition the state machine to idle from active app state. Returns nil. " - (fsm.dispatch :leave-app app-name)) + (fsm.signal :leave-app app-name)) (fn launch [app-name] @@ -92,7 +92,7 @@ This module works mechanically similar to lib/modal.fnl. Calls the launch lifecycle method defined for an app in config.fnl Returns nil. " - (fsm.dispatch :launch-app app-name)) + (fsm.signal :launch-app app-name)) (fn close [app-name] @@ -102,7 +102,8 @@ This module works mechanically similar to lib/modal.fnl. Calls the exit lifecycle method defined for an app in config.fnl Returns nil. " - (fsm.dispatch :close-app app-name)) + (fsm.signal :close-app app-name)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Set Key Bindings @@ -140,113 +141,75 @@ This module works mechanically similar to lib/modal.fnl. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn ->enter - [state app-name] + [state action app-name] " Transition the app state machine from the general, shared key bindings to an app we have local keybindings for. - Runs the following side-effects - - Unbinds the previous app local keys if there were any set - - Calls the :deactivate method of previous app config.fnl table lifecycle - precautionary in case it was set by a previous app in use - - Calls the :activate method of the current app config.fnl table if config - exists for current app - Takes the current app state machine state table - Returns the next app state machine state table - " - (let [{:apps apps - :app prev-app - :unbind-keys unbind-keys} state - next-app (find (by-key app-name) apps)] - (when next-app - (call-when unbind-keys) - (lifecycle.deactivate-app prev-app) - (lifecycle.activate-app next-app) - {:status :in-app - :app next-app - :unbind-keys (bind-app-keys next-app.keys) - :action :enter-app}))) - -(fn in-app->enter - [state app-name] - " - Transition the app state machine from an app the user was using with local keybindings - to another app that may or may not have local keybindings. - Runs the following side-effects - - Unbinds the previous app local keys - - Calls the :deactivate method of previous app config.fnl table lifecycle - - Calls the :activate method of the current app config.fnl table for the new app - that we are activating + Kicks off an effect to bind app-specific keys. Takes the current app state machine state table - Returns the next app state machine state table + Returns update modal state machine state table. " - (let [{:apps apps - :app prev-app - :unbind-keys unbind-keys} state + (let [{: apps + : app} state.context next-app (find (by-key app-name) apps)] - (when next-app - (call-when unbind-keys) - (lifecycle.deactivate-app prev-app) - (lifecycle.activate-app next-app) - {:status :in-app - :app next-app - :unbind-keys (bind-app-keys next-app.keys) - :action :enter-app}))) + (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :enter-app-effect})) -(fn in-app->leave - [state app-name] - " - Transition the app state machine from an app the user was using with local keybindings - to another app that may or may not have local keybindings. - Runs the following side-effects - - Unbinds the previous app local keys - - Calls the :deactivate method of previous app config.fnl table lifecycle - - Calls the :activate method of the current app config.fnl table for the new app - that we are activating - Takes the current app state machine state table - Returns the next app state machine state table - " - (let [{:apps apps - :app current-app - :unbind-keys unbind-keys} state] - (if (= current-app.key app-name) - (do - (call-when unbind-keys) - (lifecycle.deactivate-app current-app) - {:status :general-app - :app :nil - :unbind-keys :nil - :action :leave-app}) - nil))) -(fn ->launch - [state app-name] - " - Using the state machine we also react to launching apps by calling the :launch lifecycle method - on apps defined in a user's config.fnl. This way they can run hammerspoon functions when an app - is opened like say resizing emacs on launch. - Takes the current app state machine state table - Calls the lifecycle method on the given app config defined in config.fnl - Returns nil which tells the statemachine that no state updates have ocurred. - " - (let [{:apps apps} state - app-menu (find (by-key app-name) apps)] - (lifecycle.launch-app app-menu) - nil)) +(fn in-app->leave + [state action app-name] + " + Transition the app state machine from an app the user was using with local + keybindings to another app that may or may not have local keybindings. + Because a 'enter (new) app' action is fired before a 'leave (old) app', we + know that this will be called AFTER the enter transition has updated the + state, so we should not update the state. + Takes the current app state machine state table, + Kicks off an effect to run leave-app effects and unbind the old app's keys + Returns the old state. + " + (log.df "TRANSITION: in-app->leave app %s" app-name) ;; DELETEME + {:state state + :effect :leave-app-effect}) + +(fn launch-app + [state action app-name] + " + Using the state machine we also react to launching apps by calling the :launch + lifecycle method on apps defined in a user's config.fnl. This way they can run + hammerspoon functions when an app is opened like say resizing emacs on launch. + Takes the current app state machine state table. + Kicks off an effect to bind app-specific keys & fire launch app lifecycle + Returns a new state. + " + (let [{: apps + : app} state + next-app (find (by-key app-name) apps)] + (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :launch-app-effect})) (fn ->close - [state app-name] + [state action app-name] " - Using the state machine we also react to launching apps by calling the :close lifecycle method - on apps defined in a user's config.fnl. This way they can run hammerspoon functions when an app - is closed. For instance re-enabling vim mode when an app is closed that was incompatible + Using the state machine we also react to launching apps by calling the :close + lifecycle method on apps defined in a user's config.fnl. This way they can run + hammerspoon functions when an app is closed. For instance re-enabling vim mode + when an app is closed that was incompatible Takes the current app state machine state table - Calls the lifecycle method on the given app config defined in config.fnl - Returns nil which tells the statemachine that no state updates have ocurred. + Kicks off an effect to bind app-specific keys + Returns the old state " - (let [{:apps apps} state - app-menu (find (by-key app-name) apps)] - (lifecycle.close-app app-menu) - nil)) + (log.df "TRANSITION: ->close app app-name %s" app-name) ;; DELETEME + {:state state + :effect :close-app-effect}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -261,22 +224,17 @@ Defines the two states our app state machine can be in: modal menu items, or lifecycle methods to trigger other hammerspoon functions Maps each state to a table of actions mapped to handlers responsible for returning the next state the statemachine is in. - -TODO: Currently each handler function is responsible for performing transition - side effects like cleaning up previous key bindings and lifecycle methods - as well as returning the next statemachine state. - In the near future we can likely separate those responsibilities out more - akin to something like ClojureScript's re-frame or JS's redux. " + (local states - {:general-app {:enter-app ->enter - :leave-app noop - :launch-app ->launch - :close-app ->close} - :in-app {:enter-app in-app->enter - :leave-app in-app->leave - :launch-app ->launch - :close-app ->close}}) + {:general-app {:enter-app ->enter + :leave-app noop + :launch-app launch-app + :close-app ->close} + :in-app {:enter-app ->enter + :leave-app in-app->leave + :launch-app launch-app + :close-app ->close}}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -309,6 +267,7 @@ Assign some simple keywords for each hs.application.watcher event type. Returns nil. Relies on side-effects. " (let [event-type (. app-events event)] + (log.df "Got watch-apps event %s" event-type) ;; DELETEME (if (= event-type :activated) (enter app-name) (= event-type :deactivated) @@ -339,23 +298,20 @@ Assign some simple keywords for each hs.application.watcher event type. fsm.state :log-state (fn log-state [state] - (log.df "app is now: %s" (and state.app state.app.key))))) + (log.df "app is now: %s" (and state.context.app state.context.app.key))))) -(fn proxy-actions - [fsm] +(fn action-watcher + [{: prev-state : next-state : action : effect : extra}] " Internal API function to emit app-specific state machine events and transitions to other state machines. Like telling our modal state machine the user has entered into emacs so display the emacs-specific menu modal. - Takes the apps finite state machine instance. - Performs a side-effect to watch the finite-state-machine and log each action - to a list of actions other FSMs can subscribe to like a stream. + Subscribes to the apps state machine. + Takes a transition record from the FSM. Returns nil. " - (atom.add-watch fsm.state :actions - (fn action-watcher - [state] - (emit state.action state.app)))) + (log.df "PROXY action %s effect %s extra %s app %s" action effect extra (and next-state.context.app next-state.context.app.key)) ; DELETEME + (emit action next-state.context.app)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -373,7 +329,7 @@ Assign some simple keywords for each hs.application.watcher event type. " (when fsm (let [state (atom.deref fsm.state)] - state.app))) + state.context.app))) (fn subscribe [f] @@ -390,6 +346,80 @@ Assign some simple keywords for each hs.application.watcher event type. [] (atom.remove-watch actions key)))) +(fn enter-app-effect + [context] + " + Bind keys and lifecycle for the new current app. + Return a cleanup function to cleanup these bindings. + " + (when context.app + (lifecycle.activate-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys))))) + +(fn launch-app-effect + [context] + " + Bind keys and lifecycle for the next current app. + Return a cleanup function to cleanup these bindings. + " + (when context.app + (lifecycle.launch-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys))))) + +(fn my-effect-handler + [effect-map] + " + Takes a map of effect->function and returns a function that handles these + effects by calling the mapped-to function, and then calls that function's + return value (a cleanup function) and calls it on the next transition. + + Unlike the fsm's effect-handler, these are app-aware and only call the cleanup + function for that particular app. + + These functions must return their own cleanup function or nil. + " + ;; Create a one-time atom used to store the cleanup function map + (let [cleanup-ref (atom.new {})] + ;; Return a subscriber function + (fn [{: prev-state : next-state : action : effect : extra}] + ;; Whenever a transition occurs, call the cleanup function for that + ;; particular app, if set + (log.df "EFFECTS HANDLER for effect %s on app %s" effect extra) ;; DELETEME + ;; Call the cleanup function for this app if it's set + (call-when (. (atom.deref cleanup-ref) extra)) + (let [cleanup-map (atom.deref cleanup-ref) + effect-func (. effect-map effect)] + (log.df "Cleanup map: %s" (hs.inspect cleanup-map)) ;; DELETEME + ;; Update the cleanup entry for this app with a new func or nil + (atom.reset! cleanup-ref + (merge cleanup-map + {extra (call-when effect-func next-state extra)})))))) + +(local apps-effect + (my-effect-handler + {:enter-app-effect (fn [state extra] + (log.df "EFFECT: enter-app") ;; DELETEME + (enter-app-effect state.context)) + :leave-app-effect (fn [state extra] + (log.df "EFFECT: leave-app") ;; DELETEME + (lifecycle.deactivate-app state.context.app) + nil) + :launch-app-effect (fn [state extra] + (log.df "EFFECT: launch-app") ;; DELETEME + (launch-app-effect state.context)) + :close-app-effect (fn [state extra] + (log.df "EFFECT: close-app") ;; DELETEME + (lifecycle.close-app state.context.app) + nil)})) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Initialization @@ -404,15 +434,17 @@ Assign some simple keywords for each hs.application.watcher event type. Returns a function to cleanup the hs.application.watcher. " (let [active-app (active-app-name) - initial-state {:apps config.apps - :app nil - :status :general-app - :unbind-keys nil - :action nil} + initial-context {:apps config.apps + :app nil} + template {:state {:current-state :general-app + :context initial-context} + :states states + :log "apps"} app-watcher (hs.application.watcher.new watch-apps)] - (set fsm (statemachine.new states initial-state :status)) + (set fsm (statemachine.new template)) + (fsm.subscribe apps-effect) (start-logger fsm) - (proxy-actions fsm) + (fsm.subscribe action-watcher) (enter active-app) (: app-watcher :start) (fn cleanup [] @@ -424,6 +456,6 @@ Assign some simple keywords for each hs.application.watcher event type. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{:init init - :get-app get-app - :subscribe subscribe} +{: init + : get-app + : subscribe} diff --git a/lib/modal.fnl b/lib/modal.fnl index 546984f..632b7a2 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -2,9 +2,9 @@ Displays the menu modals, sub-menus, and application-specific modals if set in config.fnl. -We define a state machine, which uses our local states to determine states, -and transitions. Then we can dispatch events that attempt to transition -between specific states defined in the table. +We define a state machine, which uses our local states to determine states, and +transitions. Then we can signal events that may transition between specific +states defined in the table. Allows us to create the machinery for displaying, entering, exiting, and switching menus in one place which is then powered by config.fnl. @@ -22,9 +22,7 @@ switching menus in one place which is then powered by config.fnl. : identity : join : map - : merge - : noop - : slice} + : merge} (require :lib.functional)) (local {:align-columns align-columns} (require :lib.text)) @@ -33,7 +31,7 @@ switching menus in one place which is then powered by config.fnl. (require :lib.bind)) (local lifecycle (require :lib.lifecycle)) -(local log (hs.logger.new "\tmodal.fnl\t" "debug")) +(local log (hs.logger.new "modal.fnl" "debug")) (var fsm nil) @@ -70,7 +68,7 @@ switching menus in one place which is then powered by config.fnl. specific menu key. Side effectful " - (fsm.dispatch :activate menu-key)) + (fsm.signal :activate menu-key)) (fn deactivate-modal @@ -80,7 +78,7 @@ switching menus in one place which is then powered by config.fnl. Takes no arguments. Side effectful " - (fsm.dispatch :deactivate)) + (fsm.signal :deactivate)) (fn previous-modal @@ -89,7 +87,7 @@ switching menus in one place which is then powered by config.fnl. API to transition to the previous modal in our history. Useful for returning to the main menu when in the window modal for instance. " - (fsm.dispatch :previous)) + (fsm.signal :previous)) (fn start-modal-timeout @@ -103,7 +101,7 @@ switching menus in one place which is then powered by config.fnl. Takes no arguments. Side effectful " - (fsm.dispatch :start-timeout)) + (fsm.signal :start-timeout)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -248,35 +246,24 @@ switching menus in one place which is then powered by config.fnl. :strokeWidth 0} 99999))) - (fn show-modal-menu - [{:menu menu - :prev-menu prev-menu - :unbind-keys unbind-keys - :stop-timeout stop-timeout - :history history}] + [context] " Main API to display a modal and run side-effects - - Unbind keys of previous modal if set - - Stop modal timeout that closes the modal after inactivity - - Call the exit-menu lifecycle method on previous menu if set - - Call the enter-menu lifecycle method on new menu if set - Display the modal alert Takes current modal state from our modal statemachine - Returns updated modal state to store in the modal statemachine + Returns the function to cleanup everything it sets up " - (call-when unbind-keys) - (call-when stop-timeout) - (lifecycle.exit-menu prev-menu) - (lifecycle.enter-menu menu) - (modal-alert menu) - {:menu menu - :stop-timeout :nil - :unbind-keys (bind-menu-keys menu.items) - :history (if history - (conj history menu) - [menu])}) - + (lifecycle.enter-menu context.menu) + (modal-alert context.menu) + (let [unbind-keys (bind-menu-keys context.menu.items) + stop-timeout context.stop-timeout] + (fn [] + (hs.alert.closeAll 0) + (unbind-keys) + (call-when stop-timeout) + (lifecycle.exit-menu context.menu) + ))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Menus, & Config Navigation @@ -295,120 +282,107 @@ switching menus in one place which is then powered by config.fnl. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; State Transitions +;; State Transition Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn idle->active - [state data] + [state action extra] " Transition our modal statemachine from the idle state to active where a menu modal is displayed to the user. Takes the current modal state table plus the key of the menu if submenu - Displays the modal or local app menu if specified + Kicks off an effect to display the modal or local app menu Returns updated modal state machine state table. " - (let [{:config config - :stop-timeout stop-timeout - :unbind-keys unbind-keys} state + (let [config state.context.config app-menu (apps.get-app) menu (if (and app-menu (has-some? app-menu.items)) app-menu config)] - (merge {:status :active} - (show-modal-menu {:menu menu - :stop-timeout stop-timeout - :unbind-keys unbind-keys})))) + (log.df "TRANSITION: idle->active app-menu %s menu %s config %s" (and app-menu app-menu.key) menu config) ;; DELETEME + {:state {:current-state :active + :context (merge state.context {:menu menu + :history (if state.history + (conj history menu) + [menu])})} + :effect :show-modal-menu})) (fn active->idle - [state _] + [state action extra] " - Transition our modal state machine from the active, open state to idle by - closing the modal. + Transition our modal state machine from the active, open state to idle. Takes the current modal state table. - Closes the modal, stops the close timeout, and unbinds modal keys - Returns new modal state + Kicks off an effect to close the modal, stop the timeout, and unbind keys + Returns updated modal state machine state table. " - (let [{:menu prev-menu} state] - (hs.alert.closeAll 0) - (call-when state.stop-timeout) - (call-when state.unbind-keys) - (lifecycle.exit-menu prev-menu) - {:status :idle - :menu :nil - :stop-timeout :nil - :history [] - :unbind-keys :nil})) + (log.df "TRANSITION: active->idle") ;; DELETEME + {:state {:current-state :idle + :context (merge state.context {:menu :nil + :history []})} + :effect :close-modal-menu}) -(fn active->enter-app - [state app-menu] +(fn ->enter-app + [state action extra] " - Transition our modal state machine that is already open to an app menu + Transition our modal state machine the main menu to an app menu Takes the current modal state table and the app menu table. Displays updated modal menu if the current menu is different than the previous menu otherwise results in no operation Returns new modal state " + (log.df "TRANSITION: ->enter-app action %s extra %s" action extra) ;; DELETEME (let [{:config config - :menu prev-menu - :stop-timeout stop-timeout - :unbind-keys unbind-keys - :history history} state + :menu prev-menu} state.context + app-menu (apps.get-app) menu (if (and app-menu (has-some? app-menu.items)) app-menu config)] (if (= menu.key prev-menu.key) + ; nil transition object means keep all state nil - (merge {:history [menu]} - (show-modal-menu - {:stop-timeout stop-timeout - :unbind-keys unbind-keys - :menu menu - :history history}))))) + {:state {:current-state :submenu + :context (merge state.context {:menu menu})} + :effect :open-submenu}))) (fn active->leave-app - [state] + [state action extra] " Transition to the regular menu when user removes focus (blurs) another app. If the leave event was fired for the app we are already in, do nothing. Takes the current modal state table. Returns new updated modal state if we are leaving the current app. " + (log.df "TRANSITION: active->leave-app") ;; DELETEME (let [{:config config - :menu prev-menu} state] + :menu prev-menu} state.context] (if (= prev-menu.key config.key) nil - (idle->active state)))) + (idle->active state action extra)))) (fn active->submenu - [state menu-key] + [state action menu-key] " Enter a submenu like entering into the Window menu from the default main menu. - Takes the current menu state table and the submenu ke. + Takes the current menu state table and the submenu key as 'extra'. Returns updated menu state " (let [{:config config - :menu prev-menu - :stop-timeout stop-timeout - :unbind-keys unbind-keys - :history history} state + :menu prev-menu} state.context menu (if menu-key (find (by-key menu-key) prev-menu.items) config)] - (when menu - (merge {:status :submenu} - (show-modal-menu {:stop-timeout stop-timeout - :unbind-keys unbind-keys - :prev-menu prev-menu - :menu menu - :history history}))))) + (log.df "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME + {:state {:current-state :submenu + :context (merge state.context {:menu menu})} + :effect :open-submenu})) -(fn active->timeout - [state] +(fn add-timeout-transition + [state action extra] " Transition from active to idle, but this transition only fires when the timeout occurs. The timeout is only started after firing a repeatable action. @@ -417,13 +391,16 @@ switching menus in one place which is then powered by config.fnl. more modal keypresses until the timeout triggers which will deactivate the modal. Takes the current modal state table. - Returns a partial modal state table to merge into the modal state. + Returns a the old state with a :stop-timeout added " - (call-when state.stop-timeout) - {:stop-timeout (timeout deactivate-modal)}) + (log.df "TRANSITION: add-timeout-transition") ;; DELETEME + {:state {:current-state state.current-state + :context + (merge state.context {:stop-timeout (timeout deactivate-modal)})} + :effect :open-submenu}) (fn submenu->previous - [state] + [state action extra] " Transition to the previous submenu. Like if you went into the window menu and wanted to go back to the main menu. @@ -432,15 +409,15 @@ switching menus in one place which is then powered by config.fnl. Dynamically calls another transition depending on history. " (let [{:config config - :history history - :menu menu} state - prev-menu (. history (- (length history) 1))] + :history hist + :menu menu} state.context + prev-menu (. hist (- (length hist) 1))] + (log.df "TRANSITION: submenu->previous") ;; DELETEME (if prev-menu - (merge state - (show-modal-menu (merge state - {:menu prev-menu - :prev-menu menu})) - {:history (butlast history)}) + {:state {:current-state :submenu + :context (merge state.context {:menu prev-menu + :history (butlast hist)})} + :effect :open-submenu} (idle->active state)))) @@ -450,26 +427,19 @@ switching menus in one place which is then powered by config.fnl. ;; State machine states table. Maps states to actions to transition functions. -;; Our state machine implementation is a bit naive in that the transition can -;; return the new state that it's in by updating the status. -;; -;; We can make it more rigid if necessary but can be helpful when navigating -;; submenus or leaving apps. +;; These transition functions return transition objects that contain the new +;; state key and context. (local states - {:idle {:activate idle->active - :enter-app noop - :leave-app noop} + {:idle {:activate idle->active} :active {:deactivate active->idle :activate active->submenu - :start-timeout active->timeout - :enter-app active->enter-app - :leave-app active->leave-app} + :start-timeout add-timeout-transition + :enter-app ->enter-app} :submenu {:deactivate active->idle :activate active->submenu :previous submenu->previous - :start-timeout active->timeout - :enter-app noop - :leave-app noop}}) + :start-timeout add-timeout-transition + :enter-app ->enter-app}}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -489,9 +459,20 @@ switching menus in one place which is then powered by config.fnl. fsm.state :log-state (fn log-state [state] - (log.df "state is now: %s" state.status) - (when state.history - (log.df (hs.inspect (map #(. $1 :title) state.history))))))) + (log.df "state is now: %s" state.current-state) ;; DELETEME + (when state.context.history + (log.df (hs.inspect (map #(. $1 :title) state.context.history))))))) + +; TODO: Bind show-modal-menu direct +; TODO: Do we only need one effect? +(local modal-effect + (statemachine.effect-handler + {:show-modal-menu (fn [state extra] + (log.df "Effect: show modal") ;; DELETEME + (show-modal-menu state.context)) + :open-submenu (fn [state extra] + (log.df "Effect: Open submenu with extra %s" extra) ;; DELETEME + (show-modal-menu state.context))})) (fn proxy-app-action [[action data]] @@ -503,6 +484,7 @@ switching menus in one place which is then powered by config.fnl. Executes a side-effect Returns nil " + (log.df "PROXY FROM APPS action %s data %s" action data) ; DELETEME (fsm.dispatch action data)) @@ -519,14 +501,17 @@ switching menus in one place which is then powered by config.fnl. Causes side effects to start the state machine, show the modal, and logging. Returns a function to unsubscribe from the app state machine. " - (let [initial-state {:config config - :history [] - :menu nil - :status :idle - :stop-timeout nil - :unbind-keys nil} + (let [initial-context {:config config + :history [] + :menu :nil} + template {:state {:current-state :idle + :context initial-context} + :states states + :log "modal"} unsubscribe (apps.subscribe proxy-app-action)] - (set fsm (statemachine.new states initial-state :status)) + (set fsm (statemachine.new template)) + (tset fsm :dispatch fsm.signal) ; DELETEME: TEMP: Monkey patch dispatch to show dispatchers haven't changed + (fsm.subscribe modal-effect) (start-logger fsm) (fn cleanup [] (unsubscribe)))) @@ -538,5 +523,4 @@ switching menus in one place which is then powered by config.fnl. {: init - : bind-menu-keys : activate-modal} diff --git a/lib/new-apps.fnl b/lib/new-apps.fnl deleted file mode 100644 index a0d1e06..0000000 --- a/lib/new-apps.fnl +++ /dev/null @@ -1,461 +0,0 @@ -" -Creates a finite state machine to handle app-specific events. -A user may specify app-specific key bindings or menu items in their config.fnl - -Uses a state machine to better organize logic for entering apps we have config -for, versus switching between apps, versus exiting apps, versus activating apps. - -This module works mechanically similar to lib/modal.fnl. -" -(local atom (require :lib.atom)) -(local statemachine (require :lib.new-statemachine)) -(local os (require :os)) -(local {: call-when - : find - : merge - : noop - : tap} - (require :lib.functional)) -(local {:action->fn action->fn - :bind-keys bind-keys} - (require :lib.bind)) -(local lifecycle (require :lib.lifecycle)) - - -(local log (hs.logger.new "new-apps.fnl" "debug")) - -(local actions (atom.new nil)) -;; Create a dynamic var to hold an accessible instance of our finite state -;; machine for apps. -(var fsm nil) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Utils -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn gen-key - [] - " - Generate a unique, random, base64 encoded string 7 chars long. - Takes no arguments. - Side effectful. - Returns unique 7 char, randomized string. - " - (var nums "") - (for [i 1 7] - (set nums (.. nums (math.random 0 9)))) - (string.sub (hs.base64.encode nums) 1 7)) - -(fn emit - [action data] - " - When an action occurs in our state machine we want to broadcast it for systems - like modals to transition. - Takes action name and data to transition another finite state machine. - Side-effect: Updates the actions atom. - Returns nil. - " - (atom.swap! actions (fn [] [action data]))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Action signalers -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn enter - [app-name] - " - Action to focus or activate an app. App must have either menu options - or key bindings defined in config.fnl. - - Takes the name of the app we entered. - Transitions to the entered finite-state-machine state. - Returns nil. - " - (fsm.signal :enter-app app-name)) - -(fn leave - [app-name] - " - The user has deactivated\blurred an app we have config defined. - Takes the name of the app the user deactivated. - Transition the state machine to idle from active app state. - Returns nil. - " - (fsm.signal :leave-app app-name)) - -(fn launch - [app-name] - " - The user launched an app we have config defined for. - Takes name of the app launched. - Calls the launch lifecycle method defined for an app in config.fnl - Returns nil. - " - (fsm.signal :launch-app app-name)) - -(fn close - [app-name] - " - The user closed an app we have config defined for. - Takes name of the app closed. - Calls the exit lifecycle method defined for an app in config.fnl - Returns nil. - " - (fsm.signal :close-app app-name)) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Set Key Bindings -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn bind-app-keys - [items] - " - Bind config.fnl app keys to actions - Takes a list of local app bindings - Returns a function to call without arguments to remove bindings. - " - (bind-keys items)) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Apps Navigation -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn by-key - [target] - " - Checker to search for app definitions to find the app with a key property - that matches the target. - Takes a target key string - Returns a predicate that takes an app menu table and returns true if - app.key == target - " - (fn [app] - (= app.key target))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; State Transitions -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn ->enter - [state action app-name] - " - Transition the app state machine from the general, shared key bindings to an - app we have local keybindings for. - Kicks off an effect to bind app-specific keys. - Takes the current app state machine state table - Returns update modal state machine state table. - " - (let [{: apps - : app} state.context - next-app (find (by-key app-name) apps)] - (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :enter-app-effect})) - - -(fn in-app->leave - [state action app-name] - " - Transition the app state machine from an app the user was using with local - keybindings to another app that may or may not have local keybindings. - Because a 'enter (new) app' action is fired before a 'leave (old) app', we - know that this will be called AFTER the enter transition has updated the - state, so we should not update the state. - Takes the current app state machine state table, - Kicks off an effect to run leave-app effects and unbind the old app's keys - Returns the old state. - " - (log.df "TRANSITION: in-app->leave app %s" app-name) ;; DELETEME - {:state state - :effect :leave-app-effect}) - -(fn launch-app - [state action app-name] - " - Using the state machine we also react to launching apps by calling the :launch - lifecycle method on apps defined in a user's config.fnl. This way they can run - hammerspoon functions when an app is opened like say resizing emacs on launch. - Takes the current app state machine state table. - Kicks off an effect to bind app-specific keys & fire launch app lifecycle - Returns a new state. - " - (let [{: apps - : app} state - next-app (find (by-key app-name) apps)] - (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :launch-app-effect})) - -(fn ->close - [state action app-name] - " - Using the state machine we also react to launching apps by calling the :close - lifecycle method on apps defined in a user's config.fnl. This way they can run - hammerspoon functions when an app is closed. For instance re-enabling vim mode - when an app is closed that was incompatible - Takes the current app state machine state table - Kicks off an effect to bind app-specific keys - Returns the old state - " - (log.df "TRANSITION: ->close app app-name %s" app-name) ;; DELETEME - {:state state - :effect :close-app-effect}) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Finite State Machine States -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -" -State machine transition definitions -Defines the two states our app state machine can be in: -1. General, non-specific app where no table defined in config.fnl exists -2. In a specific app where a table is defined to customize local keys, - modal menu items, or lifecycle methods to trigger other hammerspoon functions -Maps each state to a table of actions mapped to handlers responsible for -returning the next state the statemachine is in. -" - -(local states - {:general-app {:enter-app ->enter - :leave-app noop - :launch-app launch-app - :close-app ->close} - :in-app {:enter-app ->enter - :leave-app in-app->leave - :launch-app launch-app - :close-app ->close}}) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Watchers, Dispatchers, & Logging -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -" -Assign some simple keywords for each hs.application.watcher event type. -" -(local app-events - {hs.application.watcher.activated :activated - hs.application.watcher.deactivated :deactivated - hs.application.watcher.hidden :hidden - hs.application.watcher.launched :launched - hs.application.watcher.launching :launching - hs.application.watcher.terminated :terminated - hs.application.watcher.unhidden :unhidden}) - - -(fn watch-apps - [app-name event app] - " - Hammerspoon application watcher callback - Looks up the event type based on our keyword mappings and dispatches the - corresponding action against the state machine to manage side-effects and - update their state. - - Takes the name of the app, the hs.application.watcher event-type, an the - hs.application.instance that triggered the event. - Returns nil. Relies on side-effects. - " - (let [event-type (. app-events event)] - (log.df "Got watch-apps event %s" event-type) ;; DELETEME - (if (= event-type :activated) - (enter app-name) - (= event-type :deactivated) - (leave app-name) - (= event-type :launched) - (launch app-name) - (= event-type :terminated) - (close app-name)))) - -(fn active-app-name - [] - " - Internal API function to return the name of the frontmost app - Returns the name of the app if there is a frontmost app or nil. - " - (let [app (hs.application.frontmostApplication)] - (if app - (: app :name) - nil))) - -(fn start-logger - [fsm] - " - Debugging handler to add a watcher to the apps finite-state-machine - state atom to log changes over time. - " - (atom.add-watch - fsm.state :log-state - (fn log-state - [state] - (log.df "app is now: %s" (and state.context.app state.context.app.key))))) - -(fn action-watcher - [{: prev-state : next-state : action : effect : extra}] - " - Internal API function to emit app-specific state machine events and transitions to - other state machines. Like telling our modal state machine the user has - entered into emacs so display the emacs-specific menu modal. - Subscribes to the apps state machine. - Takes a transition record from the FSM. - Returns nil. - " - (log.df "PROXY action %s effect %s extra %s app %s" action effect extra (and next-state.context.app next-state.context.app.key)) ; DELETEME - (emit action next-state.context.app)) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; API Methods -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn get-app - [] - " - Public API method to get the user's config table for the current app defined - in their config.fnl. - Takes no arguments. - Returns the current app config table or nil if no config was defined for the - current app. - " - (when fsm - (let [state (atom.deref fsm.state)] - state.context.app))) - -(fn subscribe - [f] - " - Public API to subscribe to the stream atom of app specific actions. - Allows the menu modal FSM to subscribe to app actions to know when to switch - to an app specific menu or revert back to default main menu. - Takes a function to call on each action update. - Returns a function to remove the subscription to actions stream. - " - (let [key (gen-key)] - (atom.add-watch actions key f) - (fn unsubscribe - [] - (atom.remove-watch actions key)))) - -(fn enter-app-effect - [context] - " - Bind keys and lifecycle for the new current app. - Return a cleanup function to cleanup these bindings. - " - (when context.app - (lifecycle.activate-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME - (fn [] - (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME - (unbind-keys))))) - -(fn launch-app-effect - [context] - " - Bind keys and lifecycle for the next current app. - Return a cleanup function to cleanup these bindings. - " - (when context.app - (lifecycle.launch-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME - (fn [] - (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME - (unbind-keys))))) - -(fn my-effect-handler - [effect-map] - " - Takes a map of effect->function and returns a function that handles these - effects by calling the mapped-to function, and then calls that function's - return value (a cleanup function) and calls it on the next transition. - - Unlike the fsm's effect-handler, these are app-aware and only call the cleanup - function for that particular app. - - These functions must return their own cleanup function or nil. - " - ;; Create a one-time atom used to store the cleanup function map - (let [cleanup-ref (atom.new {})] - ;; Return a subscriber function - (fn [{: prev-state : next-state : action : effect : extra}] - ;; Whenever a transition occurs, call the cleanup function for that - ;; particular app, if set - (log.df "EFFECTS HANDLER for effect %s on app %s" effect extra) ;; DELETEME - ;; Call the cleanup function for this app if it's set - (call-when (. (atom.deref cleanup-ref) extra)) - (let [cleanup-map (atom.deref cleanup-ref) - effect-func (. effect-map effect)] - (log.df "Cleanup map: %s" (hs.inspect cleanup-map)) ;; DELETEME - ;; Update the cleanup entry for this app with a new func or nil - (atom.reset! cleanup-ref - (merge cleanup-map - {extra (call-when effect-func next-state extra)})))))) - -(local apps-effect - (my-effect-handler - {:enter-app-effect (fn [state extra] - (log.df "EFFECT: enter-app") ;; DELETEME - (enter-app-effect state.context)) - :leave-app-effect (fn [state extra] - (log.df "EFFECT: leave-app") ;; DELETEME - (lifecycle.deactivate-app state.context.app) - nil) - :launch-app-effect (fn [state extra] - (log.df "EFFECT: launch-app") ;; DELETEME - (launch-app-effect state.context)) - :close-app-effect (fn [state extra] - (log.df "EFFECT: close-app") ;; DELETEME - (lifecycle.close-app state.context.app) - nil)})) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Initialization -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn init - [config] - " - Initialize apps finite-state-machine and create hs.application.watcher - instance to listen for app specific events. - Takes the current config.fnl table - Returns a function to cleanup the hs.application.watcher. - " - (let [active-app (active-app-name) - initial-context {:apps config.apps - :app nil} - template {:state {:current-state :general-app - :context initial-context} - :states states - :log "apps"} - app-watcher (hs.application.watcher.new watch-apps)] - (set fsm (statemachine.new template)) - (fsm.subscribe apps-effect) - (start-logger fsm) - (fsm.subscribe action-watcher) - (enter active-app) - (: app-watcher :start) - (fn cleanup [] - (: app-watcher :stop)))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Exports -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - - -{: init - : get-app - : subscribe} diff --git a/lib/new-modal.fnl b/lib/new-modal.fnl deleted file mode 100644 index d8fc204..0000000 --- a/lib/new-modal.fnl +++ /dev/null @@ -1,526 +0,0 @@ -" -Displays the menu modals, sub-menus, and application-specific modals if set -in config.fnl. - -We define a state machine, which uses our local states to determine states, and -transitions. Then we can signal events that may transition between specific -states defined in the table. - -Allows us to create the machinery for displaying, entering, exiting, and -switching menus in one place which is then powered by config.fnl. -" -(local atom (require :lib.atom)) -(local statemachine (require :lib.new-statemachine)) -(local apps (require :lib.new-apps)) -(local {: butlast - : call-when - : concat - : conj - : find - : filter - : has-some? - : identity - : join - : map - : merge} - (require :lib.functional)) -(local {:align-columns align-columns} - (require :lib.text)) -(local {:action->fn action->fn - :bind-keys bind-keys} - (require :lib.bind)) -(local lifecycle (require :lib.lifecycle)) - -(local log (hs.logger.new "new-modal.fnl" "debug")) -(var fsm nil) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; General Utils -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn timeout - [f] - " - Create a pre-set timeout task that takes a function to run later. - Takes a function to call after 2 seconds. - Returns a function to destroy the timeout task. - " - (let [task (hs.timer.doAfter 2 f)] - (fn destroy-task - [] - (when task - (: task :stop) - nil)))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Event Dispatchers -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn activate-modal - [menu-key] - " - API to transition to the active state of our modal finite state machine - It is called by a trigger set on the outside world and provided relevant - context to determine which menu modal to activate. - Takes the name of a menu to activate or nil if it's the root menu. - menu-key refers to either a submenu key in config.fnl or an application - specific menu key. - Side effectful - " - (fsm.signal :activate menu-key)) - - -(fn deactivate-modal - [] - " - API to transition to the idle state of our modal finite state machine. - Takes no arguments. - Side effectful - " - (fsm.signal :deactivate)) - - -(fn previous-modal - [] - " - API to transition to the previous modal in our history. Useful for returning - to the main menu when in the window modal for instance. - " - (fsm.signal :previous)) - - -(fn start-modal-timeout - [] - " - API for starting a menu timeout. Some menu actions like the window navigation - actions can be repeated without having to re-enter into the Menu - Modal > Window but we don't want to be listening for key events indefinitely. - This begins a timeout that will close the modal and remove the key bindings - after a time delay specified in the timout function. - Takes no arguments. - Side effectful - " - (fsm.signal :start-timeout)) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Set Key Bindings -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn create-action-trigger - [{:action action :repeatable repeatable :timeout timeout}] - " - Creates a function to dispatch an action associated with a menu item defined - by config.fnl. - Takes a table defining the following: - - action :: function | string - Either a string like \"module:function-name\" - or a fennel function to call. - repeatable :: bool | nil - If this action is repeatable like jumping between - windows where we might wish to jump 2 windows - left and it wouldn't want to re-enter the jump menu - timeout :: bool | nil - If a timeout should be started. Defaults to true when - repeatable is true. - - Returns a function to execute the action-fn async. - " - (let [action-fn (action->fn action)] - (fn [] - (if (and repeatable (~= timeout false)) - (start-modal-timeout) - (not repeatable) - (deactivate-modal)) - ;; Delay the action-fn ever so slightly - ;; to speed up the closing of the menu - ;; This makes the UI feel slightly snappier - (hs.timer.doAfter 0.01 action-fn)))) - - -(fn create-menu-trigger - [{:key key}] - " - Takes a config menu option and returns a function to enter that submenu when - action is activated. - Returns a function to activate submenu. - " - (fn [] - (activate-modal key))) - - -(fn select-trigger - [item] - " - Transform a menu item into an action to either call a function or enter a - submenu. - Takes a menu item from config.fnl - Returns a function to perform the action associated with menu item. - " - (if (and item.action (= item.action :previous)) - previous-modal - item.action - (create-action-trigger item) - item.items - (create-menu-trigger item) - (fn [] - (log.w "No trigger could be found for item: " - (hs.inspect item))))) - - -(fn bind-item - [item] - " - Create a bindspec to map modal menu items to actions and submenus. - Takes a menu item - Returns a table to create a hs key binding. - " - {:mods (or item.mods []) - :key item.key - :action (select-trigger item)}) - - -(fn bind-menu-keys - [items] - " - Binds all actions and submenu items within a menu to VenueBook. - Takes a list of modal menu items. - Returns a function to remove menu key bindings for easy cleanup. - " - (-> items - (->> (filter (fn [item] - (or item.action - item.items))) - (map bind-item)) - (concat [{:key :ESCAPE - :action deactivate-modal} - {:mods [:ctrl] - :key "[" - :action deactivate-modal}]) - (bind-keys))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Display Modals -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(local mod-chars {:cmd "CMD" - :alt "OPT" - :shift "SHFT" - :tab "TAB"}) - -(fn format-key - [item] - " - Format the key binding of a menu item to display in a modal menu to user - Takes a modal menu item - Returns a string describing the key - " - (let [mods (-?>> item.mods - (map (fn [m] (or (. mod-chars m) m))) - (join " ") - (identity))] - (.. (or mods "") - (if mods " + " "") - item.key))) - - -(fn modal-alert - [menu] - " - Display a menu modal in an hs.alert. - Takes a menu table specified in config.fnl - Opens an alert modal as a side effect - Returns nil - " - (let [items (->> menu.items - (filter (fn [item] item.title)) - (map (fn [item] - [(format-key item) (. item :title)])) - (align-columns)) - text (join "\n" items)] - (hs.alert.closeAll) - (alert text - {:textFont "Menlo" - :textSize 16 - :radius 0 - :strokeWidth 0} - 99999))) - -(fn show-modal-menu - [context] - " - Main API to display a modal and run side-effects - - Display the modal alert - Takes current modal state from our modal statemachine - Returns the function to cleanup everything it sets up - " - (lifecycle.enter-menu context.menu) - (modal-alert context.menu) - (let [unbind-keys (bind-menu-keys context.menu.items) - stop-timeout context.stop-timeout] - (fn [] - (hs.alert.closeAll 0) - (unbind-keys) - (call-when stop-timeout) - (lifecycle.exit-menu context.menu) - ))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Menus, & Config Navigation -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn by-key - [target] - " - Checker function to filter menu items where key matches target - Takes a target string to look for like \"window\" - Returns true or false - " - (fn [item] - (and (= (. item :key) target) - (has-some? item.items)))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; State Transition Functions -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - - -(fn idle->active - [state action extra] - " - Transition our modal statemachine from the idle state to active where a menu - modal is displayed to the user. - Takes the current modal state table plus the key of the menu if submenu - Kicks off an effect to display the modal or local app menu - Returns updated modal state machine state table. - " - (let [config state.context.config - app-menu (apps.get-app) - menu (if (and app-menu (has-some? app-menu.items)) - app-menu - config)] - (log.df "TRANSITION: idle->active app-menu %s menu %s config %s" (and app-menu app-menu.key) menu config) ;; DELETEME - {:state {:current-state :active - :context (merge state.context {:menu menu - :history (if state.history - (conj history menu) - [menu])})} - :effect :show-modal-menu})) - - -(fn active->idle - [state action extra] - " - Transition our modal state machine from the active, open state to idle. - Takes the current modal state table. - Kicks off an effect to close the modal, stop the timeout, and unbind keys - Returns updated modal state machine state table. - " - (log.df "TRANSITION: active->idle") ;; DELETEME - {:state {:current-state :idle - :context (merge state.context {:menu :nil - :history []})} - :effect :close-modal-menu}) - - -(fn ->enter-app - [state action extra] - " - Transition our modal state machine the main menu to an app menu - Takes the current modal state table and the app menu table. - Displays updated modal menu if the current menu is different than the previous - menu otherwise results in no operation - Returns new modal state - " - (log.df "TRANSITION: ->enter-app action %s extra %s" action extra) ;; DELETEME - (let [{:config config - :menu prev-menu} state.context - app-menu (apps.get-app) - menu (if (and app-menu (has-some? app-menu.items)) - app-menu - config)] - (if (= menu.key prev-menu.key) - ; nil transition object means keep all state - nil - {:state {:current-state :submenu - :context (merge state.context {:menu menu})} - :effect :open-submenu}))) - - -(fn active->leave-app - [state action extra] - " - Transition to the regular menu when user removes focus (blurs) another app. - If the leave event was fired for the app we are already in, do nothing. - Takes the current modal state table. - Returns new updated modal state if we are leaving the current app. - " - (log.df "TRANSITION: active->leave-app") ;; DELETEME - (let [{:config config - :menu prev-menu} state.context] - (if (= prev-menu.key config.key) - nil - (idle->active state action extra)))) - - -(fn active->submenu - [state action menu-key] - " - Enter a submenu like entering into the Window menu from the default main menu. - Takes the current menu state table and the submenu key as 'extra'. - Returns updated menu state - " - (let [{:config config - :menu prev-menu} state.context - menu (if menu-key - (find (by-key menu-key) prev-menu.items) - config)] - (log.df "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME - {:state {:current-state :submenu - :context (merge state.context {:menu menu})} - :effect :open-submenu})) - -(fn add-timeout-transition - [state action extra] - " - Transition from active to idle, but this transition only fires when the - timeout occurs. The timeout is only started after firing a repeatable action. - For instance if you enter window > jump east you may want to jump again - without having to bring up the modal and enter the window submenu. We wait for - more modal keypresses until the timeout triggers which will deactivate the - modal. - Takes the current modal state table. - Returns a the old state with a :stop-timeout added - " - (log.df "TRANSITION: add-timeout-transition") ;; DELETEME - {:state {:current-state state.current-state - :context - (merge state.context {:stop-timeout (timeout deactivate-modal)})} - :effect :open-submenu}) - -(fn submenu->previous - [state action extra] - " - Transition to the previous submenu. Like if you went into the window menu - and wanted to go back to the main menu. - Takes the modal state table. - Returns a partial modal state table update. - Dynamically calls another transition depending on history. - " - (let [{:config config - :history hist - :menu menu} state.context - prev-menu (. hist (- (length hist) 1))] - (log.df "TRANSITION: submenu->previous") ;; DELETEME - (if prev-menu - {:state {:current-state :submenu - :context (merge state.context {:menu prev-menu - :history (butlast hist)})} - :effect :open-submenu} - (idle->active state)))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Finite State Machine States -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - - -;; State machine states table. Maps states to actions to transition functions. -;; These transition functions return transition objects that contain the new -;; state key and context. -(local states - {:idle {:activate idle->active} - :active {:deactivate active->idle - :activate active->submenu - :start-timeout add-timeout-transition - :enter-app ->enter-app} - :submenu {:deactivate active->idle - :activate active->submenu - :previous submenu->previous - :start-timeout add-timeout-transition - :enter-app ->enter-app}}) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Watchers, Dispatchers, & Logging -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - - -(fn start-logger - [fsm] - " - Start logging the status of the modal state machine. - Takes our finite state machine. - Returns nil - Creates a watcher of our state atom to log state changes reactively. - " - (atom.add-watch - fsm.state :log-state - (fn log-state - [state] - (log.df "state is now: %s" state.current-state) ;; DELETEME - (when state.context.history - (log.df (hs.inspect (map #(. $1 :title) state.context.history))))))) - -; TODO: Bind show-modal-menu direct -; TODO: Do we only need one effect? -(local modal-effect - (statemachine.effect-handler - {:show-modal-menu (fn [state extra] - (log.df "Effect: show modal") ;; DELETEME - (show-modal-menu state.context)) - :open-submenu (fn [state extra] - (log.df "Effect: Open submenu with extra %s" extra) ;; DELETEME - (show-modal-menu state.context))})) - -(fn proxy-app-action - [[action data]] - " - Provide a semi-public API function for other state machines to dispatch - changes to the modal menu state. Currently used by the app state machine to - tell the modal menu state machine when an app is launched, activated, - deactivated, or exited. - Executes a side-effect - Returns nil - " - (log.df "PROXY FROM APPS action %s data %s" action data) ; DELETEME - (fsm.dispatch action data)) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Initialization -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(fn init - [config] - " - Initialize the modal state machine responsible for displaying modal alerts - to the user to trigger actions defined by their config.fnl. - Takes the config.fnl table. - Causes side effects to start the state machine, show the modal, and logging. - Returns a function to unsubscribe from the app state machine. - " - (let [initial-context {:config config - :history [] - :menu :nil} - template {:state {:current-state :idle - :context initial-context} - :states states - :log "modal"} - unsubscribe (apps.subscribe proxy-app-action)] - (set fsm (statemachine.new template)) - (tset fsm :dispatch fsm.signal) ; DELETEME: TEMP: Monkey patch dispatch to show dispatchers haven't changed - (fsm.subscribe modal-effect) - (start-logger fsm) - (fn cleanup [] - (unsubscribe)))) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Exports -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - - -{: init - : activate-modal} diff --git a/lib/new-statemachine.fnl b/lib/new-statemachine.fnl deleted file mode 100644 index 58a34bc..0000000 --- a/lib/new-statemachine.fnl +++ /dev/null @@ -1,145 +0,0 @@ -" -Provides the mechanism to generate a finite state machine. - -A finite state machine defines states and some way to transition between states. - -The 'new' function takes a template, which is a table with the following schema: -{ - :state {:current-state :state1 - :context {}} - :states {:state1 {} - :state2 {} - :state3 {:leave transition-fn-leave - :exit transition-fn-exit}}} - -* The CONTEXT is any table that can be updated by TRANSITION FUNCTIONS. This - allows the client to track their own state. -* The STATES table is a map from ACTIONS to TRANSITION FUNCTIONS. -* These functions must return a TRANSITION OBJECT containing the new - :state and the :effect. -* The :state contains a (potentially changed) :current-state and a new :context, - which is updated in the state machine. -* Functions can subscribe to all signals, and are provided a TRANSITION RECORD, - which contains: - * :prev-state - * :next-state - * :action - * :effect that was kicked off from the transition function -* The subscribe method returns a function that can be called to unsubscribe. - -Additionally, we provide a helper function `effect-handler`, which is a -higher-order function that returns a function suitable to be provided to -subscribe. It takes a map of EFFECTs to handler functions. These handler -functions should return their own cleanup. The effect-handler will automatically -call this cleanup function after the next transition. For example, if you want -to bind keys when a certain effect is kicked off, write a function that binds -the keys and returns an unbind function. The unbind function will be called on -the next transition. -" - - -(require-macros :lib.macros) -(local atom (require :lib.atom)) -(local {: butlast - : call-when - : concat - : conj - : last - : merge - : slice} (require :lib.functional)) - -(local log (hs.logger.new "new-statemachine.fnl" "debug")) ;; DELETEME - ;; DELETEME - -(fn update-state - [fsm state] - (atom.swap! fsm.state (fn [_ state] state) state)) - -(fn get-transition-function - [fsm current-state action] - (. fsm.states current-state action)) - -(fn get-state - [fsm] - (atom.deref fsm.state)) - -(fn signal - [fsm action extra] - " - Based on the action and the fsm's current-state, set the new state and call - all subscribers with the previous state, new state, action, and extra. - " - (let [state (get-state fsm) - {: current-state : context} state] - (if-let [tx-fn (get-transition-function fsm current-state action)] - (let [ - _ (log.df "SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME - transition (tx-fn state action extra) - ;; _ (log.df "SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME - new-state (if transition transition.state state) - effect (if transition transition.effect nil)] - - (update-state fsm new-state) - ; Call all subscribers - (each [_ sub (pairs (atom.deref fsm.subscribers))] - (sub {:prev-state state :next-state new-state : action : effect : extra})) - true) - (do - (if fsm.log - (fsm.log.df "Action :%s does not have a transition function in state :%s" - action current-state)) - false)))) - -(fn subscribe - [fsm sub] - " - Adds a subscriber to the provided fsm. Returns a function to unsubscribe - Naive: Because each entry is keyed by the function address it doesn't allow - the same function to subscribe more than once. - " - (let [sub-key (tostring sub)] - (atom.swap! fsm.subscribers (fn [subs sub] - (merge {sub-key sub} subs)) sub) - ; Return the unsub func - (fn [] - (atom.swap! fsm.subscribers (fn [subs key] (tset subs key nil) subs) sub-key)))) - -(fn effect-handler - [effect-map] - " - Takes a map of effect->function and returns a function that handles these - effects by calling the mapped-to function, and then calls that function's - return value (a cleanup function) and calls it on the next transition. - - These functions must return their own cleanup function or nil. - " - ;; Create a one-time atom used to store the cleanup function - (let [cleanup-ref (atom.new nil)] - ;; Return a subscriber function - (fn [{: prev-state : next-state : action : effect : extra}] - ;; Whenever a transition occurs, call the cleanup function, if set - (call-when (atom.deref cleanup-ref)) - ;; Get a new cleanup function or nil and update cleanup-ref atom - (atom.reset! cleanup-ref - (call-when (. effect-map effect) next-state extra))))) - -(fn create-machine - [template] - (let [fsm {:state (atom.new {:current-state template.state.current-state :context template.state.context}) - :states template.states - :subscribers (atom.new {}) - :log (if template.log (hs.logger.new template.log "info"))}] - ; Add methods - (tset fsm :get-state (partial get-state fsm)) - (tset fsm :signal (partial signal fsm)) - (tset fsm :subscribe (partial subscribe fsm)) - fsm)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Exports -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -{: effect-handler - : signal - : subscribe - :new create-machine} diff --git a/lib/statemachine.fnl b/lib/statemachine.fnl index 72309e5..0bd3ef1 100644 --- a/lib/statemachine.fnl +++ b/lib/statemachine.fnl @@ -1,121 +1,145 @@ -(local atom (require :lib.atom)) -(local {: filter - : map - : merge} (require :lib.functional)) +" +Provides the mechanism to generate a finite state machine. -(local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) +A finite state machine defines states and some way to transition between states. -" -Transition -Takes an action fn, state, and extra action data -Returns updated state -" -(fn transition - [action-fn state data] - (action-fn state data)) +The 'new' function takes a template, which is a table with the following schema: +{ + :state {:current-state :state1 + :context {}} + :states {:state1 {} + :state2 {} + :state3 {:leave transition-fn-leave + :exit transition-fn-exit}}} +* The CONTEXT is any table that can be updated by TRANSITION FUNCTIONS. This + allows the client to track their own state. +* The STATES table is a map from ACTIONS to TRANSITION FUNCTIONS. +* These functions must return a TRANSITION OBJECT containing the new + :state and the :effect. +* The :state contains a (potentially changed) :current-state and a new :context, + which is updated in the state machine. +* Functions can subscribe to all signals, and are provided a TRANSITION RECORD, + which contains: + * :prev-state + * :next-state + * :action + * :effect that was kicked off from the transition function +* The subscribe method returns a function that can be called to unsubscribe. +Additionally, we provide a helper function `effect-handler`, which is a +higher-order function that returns a function suitable to be provided to +subscribe. It takes a map of EFFECTs to handler functions. These handler +functions should return their own cleanup. The effect-handler will automatically +call this cleanup function after the next transition. For example, if you want +to bind keys when a certain effect is kicked off, write a function that binds +the keys and returns an unbind function. The unbind function will be called on +the next transition. " -Remove Nils -Takes a dest table and an update. -For each key in update set to :nil, it is removed from the tbl. -Returns a mutated tbl with :nil keys removed. -" -(fn remove-nils - [tbl update] - (let [keys (->> update - (map (fn [v k] [v k])) - (filter (fn [[v _]] - (= v :nil))) - (map (fn [[_ k]] k)))] - (each [_ k (ipairs keys)] - (tset tbl k nil)) - tbl)) -" -Update State -Takes a state atom and an update table to merge -Updates the state-atom by merging the update table into previous state. -Returns the state-atom. -" + +(require-macros :lib.macros) +(local atom (require :lib.atom)) +(local {: butlast + : call-when + : concat + : conj + : last + : merge + : slice} (require :lib.functional)) + +(local log (hs.logger.new "statemachine.fnl" "debug")) ;; DELETEME + ;; DELETEME + (fn update-state - [state-atom update] - (when update - (atom.swap! - state-atom - (fn [state] - (-> {} - (merge state update) - (remove-nils update)))))) + [fsm state] + (atom.swap! fsm.state (fn [_ state] state) state)) -" -Dispatch Error -Prints an error explaining that we are not able to perform the target -action while in the current state. -" -(fn dispatch-error - [current-state-key action-name] - (log.wf "Could not %s from %s state" - action-name - current-state-key)) +(fn get-transition-function + [fsm current-state action] + (. fsm.states current-state action)) -" -Creates Dispatcher -Creates a dispatcher function to update the machine state atom. -If an update cannot be performed an error is printed to console. - -Takes a table of states, a state-atom, and a state-key used to store the current -state keyword/string. -Returns a function that can be used as a method of the fsm to transition to -another state. -" -(fn create-dispatcher - [states state-atom state-key] - (fn dispatch - [action data] - (let [state (atom.deref state-atom) - key (. state state-key) - action-fn (-?> states - (. key) - (. action))] - (if action-fn - (do - (update-state state-atom (transition action-fn state data)) - true) - (do - (dispatch-error key action) - false))))) +(fn get-state + [fsm] + (atom.deref fsm.state)) +(fn signal + [fsm action extra] + " + Based on the action and the fsm's current-state, set the new state and call + all subscribers with the previous state, new state, action, and extra. + " + (let [state (get-state fsm) + {: current-state : context} state] + (if-let [tx-fn (get-transition-function fsm current-state action)] + (let [ + _ (log.df "SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME + transition (tx-fn state action extra) + ;; _ (log.df "SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME + new-state (if transition transition.state state) + effect (if transition transition.effect nil)] + + (update-state fsm new-state) + ; Call all subscribers + (each [_ sub (pairs (atom.deref fsm.subscribers))] + (sub {:prev-state state :next-state new-state : action : effect : extra})) + true) + (do + (if fsm.log + (fsm.log.df "Action :%s does not have a transition function in state :%s" + action current-state)) + false)))) + +(fn subscribe + [fsm sub] + " + Adds a subscriber to the provided fsm. Returns a function to unsubscribe + Naive: Because each entry is keyed by the function address it doesn't allow + the same function to subscribe more than once. + " + (let [sub-key (tostring sub)] + (atom.swap! fsm.subscribers (fn [subs sub] + (merge {sub-key sub} subs)) sub) + ; Return the unsub func + (fn [] + (atom.swap! fsm.subscribers (fn [subs key] (tset subs key nil) subs) sub-key)))) + +(fn effect-handler + [effect-map] + " + Takes a map of effect->function and returns a function that handles these + effects by calling the mapped-to function, and then calls that function's + return value (a cleanup function) and calls it on the next transition. + + These functions must return their own cleanup function or nil. + " + ;; Create a one-time atom used to store the cleanup function + (let [cleanup-ref (atom.new nil)] + ;; Return a subscriber function + (fn [{: prev-state : next-state : action : effect : extra}] + ;; Whenever a transition occurs, call the cleanup function, if set + (call-when (atom.deref cleanup-ref)) + ;; Get a new cleanup function or nil and update cleanup-ref atom + (atom.reset! cleanup-ref + (call-when (. effect-map effect) next-state extra))))) -" -Create Machine -Creates a finite-state-machine based on the table of given states. -Takes a map-table of states and actions, an initial state table, and a key -to specify which key stores the current state string. -Returns an fsm table that manages state and can dispatch actions. - -Example: - -(local states - {:idle {:activate idle->active - :enter-app idle->in-app} - :active {:deactivate active->idle-or-in-app - :activate active->active - :enter-app active->active - :leave-app active->active} - :in-app {:activate in-app->active - :enter-app in-app->in-app - :leave-app in-app->idle}}) - -(local fsm (create-machine states {:state :idle} :state)) -(fsm.dispatch :activate {:extra :data}) -(print \"current-state: \" (hs.inspect (atom.deref (fsm.state)))) -" (fn create-machine - [states initial-state state-key] - (let [machine-state (atom.new initial-state)] - {:dispatch (create-dispatcher states machine-state state-key) - :states states - :state machine-state})) + [template] + (let [fsm {:state (atom.new {:current-state template.state.current-state :context template.state.context}) + :states template.states + :subscribers (atom.new {}) + :log (if template.log (hs.logger.new template.log "info"))}] + ; Add methods + (tset fsm :get-state (partial get-state fsm)) + (tset fsm :signal (partial signal fsm)) + (tset fsm :subscribe (partial subscribe fsm)) + fsm)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{:new create-machine} +{: effect-handler + : signal + : subscribe + :new create-machine} diff --git a/test/new-statemachine-test.fnl b/test/new-statemachine-test.fnl deleted file mode 100644 index 699873e..0000000 --- a/test/new-statemachine-test.fnl +++ /dev/null @@ -1,115 +0,0 @@ -(local is (require :lib.testing.assert)) -(local statemachine (require :lib.new-statemachine)) -(local atom (require :lib.atom)) - -(fn make-fsm - [] - (statemachine.new - ;; States that the machine can be in mapped to their actions and transitions - {:state {:current-state :closed - :context {:i 0 - :event nil}} - - :states {:closed {:toggle (fn closed->opened - [state action extra] - {:state {:current-state :opened - :context {:i (+ state.context.i 1)}} - :effect :opening})} - :opened {:toggle (fn opened->closed - [state action extra] - {:state {:current-state :closed - :context {:i (+ state.context.i 1)}} - :effect :closing})}}})) - -(describe - "State Machine" - (fn [] - - (it "Should create a new fsm in the closed state" - (fn [] - (let [fsm (make-fsm)] - (is.eq? (. (atom.deref fsm.state) :current-state) :closed "Initial state was not closed")))) - - (it "Should include some methods" - (fn [] - (let [fsm (make-fsm)] - (is.eq? (type fsm.get-state) :function "No get-state method") - (is.eq? (type fsm.signal) :function "No signal method ") - (is.eq? (type fsm.subscribe) :function "No subscribe method")))) - - (it "Should transition to opened on toggle action" - (fn [] - (let [fsm (make-fsm)] - (is.eq? (fsm.signal :toggle) true "Dispatch did not return true for handled event") - (is.eq? (. (atom.deref fsm.state) :current-state) :opened "State did not transition to opened")))) - - (it "Should transition from closed -> opened -> closed" - (fn [] - (let [fsm (make-fsm)] - (fsm.signal :toggle) - (fsm.signal :toggle) - (is.eq? (. (atom.deref fsm.state) :current-state) :closed "State did not transition back to closed") - (is.eq? (. (atom.deref fsm.state) :context :i) 2 "context.i should be 2 from 2 transitions")))) - - (it "Should not explode when dispatching an unhandled event" - (fn [] - (let [fsm (make-fsm)] - (is.eq? (fsm.signal :fail nil) false "The FSM exploded from dispatching a :fail event")))) - - (it "Subscribers should be called on events" - (fn [] - (let [fsm (make-fsm) - i (atom.new 0)] - (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1))))) - (fsm.signal :toggle) - (is.eq? (atom.deref i) 1 "The subscriber was not called")))) - - (it "Subscribers should be provided old and new context, action, effect, and extra" - (fn [] - (let [fsm (make-fsm) - _old (atom.new nil) - _new (atom.new nil) - _action (atom.new nil) - _effect (atom.new nil) - _extra (atom.new nil)] - (fsm.subscribe (fn [{: prev-state : next-state : action : effect : extra}] - (atom.swap! _old (fn [_ nv] nv) prev-state) - (atom.swap! _new (fn [_ nv] nv) next-state) - (atom.swap! _action (fn [_ nv] nv) action) - (atom.swap! _effect (fn [_ nv] nv) effect) - (atom.swap! _extra (fn [_ nv] nv) extra))) - (fsm.signal :toggle :extra) - (is.not-eq? (. (atom.deref _old) :context :i) - (. (atom.deref _new) :context :i) "Subscriber did not get old and new state") - (is.eq? (atom.deref _action) :toggle "Subscriber did not get correct action") - (is.eq? (atom.deref _effect) :opening "Subscriber did not get correct effect") - (is.eq? (atom.deref _extra) :extra "Subscriber did not get correct extra")))) - - (it "Subscribers should be able to unsubscribe" - (fn [] - (let [fsm (make-fsm)] - (let [i (atom.new 0) - unsub (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1)))))] - (fsm.signal :toggle) - (unsub) - (fsm.signal :toggle) - (is.eq? (atom.deref i) 1 "The subscriber was called after unsubscribing"))))) - - (it "Effect handler should maintain cleanup function" - (fn [] - (let [fsm (make-fsm) - effect-state (atom.new :unused) - effect-handler (statemachine.effect-handler - {:opening (fn [] - (atom.swap! effect-state - (fn [_ nv] nv) :opened) - ; Returned cleanup func - (fn [] - (atom.swap! effect-state - (fn [_ nv] nv) :cleaned)))}) - unsub (fsm.subscribe effect-handler)] - (fsm.signal :toggle) - (is.eq? (atom.deref effect-state) :opened "Effect handler should have been called") - (fsm.signal :toggle) - (is.eq? (atom.deref effect-state) :cleaned "Cleanup function should have been called") - ))))) diff --git a/test/statemachine-test.fnl b/test/statemachine-test.fnl index 7519a9b..1d838e7 100644 --- a/test/statemachine-test.fnl +++ b/test/statemachine-test.fnl @@ -6,24 +6,20 @@ [] (statemachine.new ;; States that the machine can be in mapped to their actions and transitions - {:closed {:toggle (fn closed->opened - [machine event] - {:state :opened - :context {:i (+ machine.context.i 1) - :event event}})} - :opened {:toggle (fn opened->closed - [machine event] - {:state :closed - :context {:i (+ machine.context.i 1) - :event event}})}} - - ;; Initial machine state - {:state :closed - :context {:i 0 - :event nil}} - - ;; Key that refers to current machine state - :state)) + {:state {:current-state :closed + :context {:i 0 + :event nil}} + + :states {:closed {:toggle (fn closed->opened + [state action extra] + {:state {:current-state :opened + :context {:i (+ state.context.i 1)}} + :effect :opening})} + :opened {:toggle (fn opened->closed + [state action extra] + {:state {:current-state :closed + :context {:i (+ state.context.i 1)}} + :effect :closing})}}})) (describe "State Machine" @@ -32,29 +28,88 @@ (it "Should create a new fsm in the closed state" (fn [] (let [fsm (make-fsm)] - (is.eq? (. (atom.deref fsm.state) :state) :closed "Initial state was not closed") - (is.eq? (type fsm.dispatch) :function "Dispatch was not a function")))) + (is.eq? (. (atom.deref fsm.state) :current-state) :closed "Initial state was not closed")))) + + (it "Should include some methods" + (fn [] + (let [fsm (make-fsm)] + (is.eq? (type fsm.get-state) :function "No get-state method") + (is.eq? (type fsm.signal) :function "No signal method ") + (is.eq? (type fsm.subscribe) :function "No subscribe method")))) - (it "Should transition to opened on toggle event" + (it "Should transition to opened on toggle action" (fn [] (let [fsm (make-fsm)] - (is.eq? (fsm.dispatch :toggle :opening) true "Dispatch did not return true for handled event") - (is.eq? (. (atom.deref fsm.state) :state) :opened "State did not transition to opened") - (is.eq? (. (atom.deref fsm.state) :context :event) :opening "Context data was not updated with event data")))) + (is.eq? (fsm.signal :toggle) true "Dispatch did not return true for handled event") + (is.eq? (. (atom.deref fsm.state) :current-state) :opened "State did not transition to opened")))) (it "Should transition from closed -> opened -> closed" (fn [] (let [fsm (make-fsm)] - (fsm.dispatch :toggle :opening) - (fsm.dispatch :toggle :closing) - (is.eq? (. (atom.deref fsm.state) :state) :closed "State did not transition back to closed") - (is.eq? (. (atom.deref fsm.state) :context :i) 2 "context.i should be 2 from 2 transitions") - (is.eq? (. (atom.deref fsm.state) :context :event) :closing "Context data was not updated with event data")))) + (fsm.signal :toggle) + (fsm.signal :toggle) + (is.eq? (. (atom.deref fsm.state) :current-state) :closed "State did not transition back to closed") + (is.eq? (. (atom.deref fsm.state) :context :i) 2 "context.i should be 2 from 2 transitions")))) (it "Should not explode when dispatching an unhandled event" (fn [] (let [fsm (make-fsm)] - (is.eq? (fsm.dispatch :fail nil) false "The FSM exploded from dispatching a :fail event")))) + (is.eq? (fsm.signal :fail nil) false "The FSM exploded from dispatching a :fail event")))) + (it "Subscribers should be called on events" + (fn [] + (let [fsm (make-fsm) + i (atom.new 0)] + (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1))))) + (fsm.signal :toggle) + (is.eq? (atom.deref i) 1 "The subscriber was not called")))) - )) + (it "Subscribers should be provided old and new context, action, effect, and extra" + (fn [] + (let [fsm (make-fsm) + _old (atom.new nil) + _new (atom.new nil) + _action (atom.new nil) + _effect (atom.new nil) + _extra (atom.new nil)] + (fsm.subscribe (fn [{: prev-state : next-state : action : effect : extra}] + (atom.swap! _old (fn [_ nv] nv) prev-state) + (atom.swap! _new (fn [_ nv] nv) next-state) + (atom.swap! _action (fn [_ nv] nv) action) + (atom.swap! _effect (fn [_ nv] nv) effect) + (atom.swap! _extra (fn [_ nv] nv) extra))) + (fsm.signal :toggle :extra) + (is.not-eq? (. (atom.deref _old) :context :i) + (. (atom.deref _new) :context :i) "Subscriber did not get old and new state") + (is.eq? (atom.deref _action) :toggle "Subscriber did not get correct action") + (is.eq? (atom.deref _effect) :opening "Subscriber did not get correct effect") + (is.eq? (atom.deref _extra) :extra "Subscriber did not get correct extra")))) + + (it "Subscribers should be able to unsubscribe" + (fn [] + (let [fsm (make-fsm)] + (let [i (atom.new 0) + unsub (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1)))))] + (fsm.signal :toggle) + (unsub) + (fsm.signal :toggle) + (is.eq? (atom.deref i) 1 "The subscriber was called after unsubscribing"))))) + + (it "Effect handler should maintain cleanup function" + (fn [] + (let [fsm (make-fsm) + effect-state (atom.new :unused) + effect-handler (statemachine.effect-handler + {:opening (fn [] + (atom.swap! effect-state + (fn [_ nv] nv) :opened) + ; Returned cleanup func + (fn [] + (atom.swap! effect-state + (fn [_ nv] nv) :cleaned)))}) + unsub (fsm.subscribe effect-handler)] + (fsm.signal :toggle) + (is.eq? (atom.deref effect-state) :opened "Effect handler should have been called") + (fsm.signal :toggle) + (is.eq? (atom.deref effect-state) :cleaned "Cleanup function should have been called") + ))))) From f2e18f8e24bdb7fa99dd361807f36bbfcce58f1d Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 11:51:35 -0400 Subject: [PATCH 34/48] Fix monkey patch --- lib/modal.fnl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/modal.fnl b/lib/modal.fnl index 632b7a2..46dd23e 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -485,7 +485,7 @@ switching menus in one place which is then powered by config.fnl. Returns nil " (log.df "PROXY FROM APPS action %s data %s" action data) ; DELETEME - (fsm.dispatch action data)) + (fsm.signal action data)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -510,7 +510,6 @@ switching menus in one place which is then powered by config.fnl. :log "modal"} unsubscribe (apps.subscribe proxy-app-action)] (set fsm (statemachine.new template)) - (tset fsm :dispatch fsm.signal) ; DELETEME: TEMP: Monkey patch dispatch to show dispatchers haven't changed (fsm.subscribe modal-effect) (start-logger fsm) (fn cleanup [] From b4a427ee282fdaa9da979f3e9e92dfb4ed91b000 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 12:25:33 -0400 Subject: [PATCH 35/48] Remove extra logging from apps and modal --- core.fnl | 1 + lib/apps.fnl | 56 ++++++++++++++++++--------------------------------- lib/modal.fnl | 11 ---------- 3 files changed, 21 insertions(+), 47 deletions(-) diff --git a/core.fnl b/core.fnl index ea5998e..f588700 100644 --- a/core.fnl +++ b/core.fnl @@ -216,6 +216,7 @@ Returns nil. This function causes side-effects. (local modules [:lib.hyper :vim :windows + :apps :lib.bind :lib.modal :lib.apps]) diff --git a/lib/apps.fnl b/lib/apps.fnl index 32edef5..5f5b2b0 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -152,12 +152,12 @@ This module works mechanically similar to lib/modal.fnl. (let [{: apps : app} state.context next-app (find (by-key app-name) apps)] - (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :enter-app-effect})) + (when next-app + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :enter-app-effect}))) (fn in-app->leave @@ -172,7 +172,6 @@ This module works mechanically similar to lib/modal.fnl. Kicks off an effect to run leave-app effects and unbind the old app's keys Returns the old state. " - (log.df "TRANSITION: in-app->leave app %s" app-name) ;; DELETEME {:state state :effect :leave-app-effect}) @@ -189,12 +188,12 @@ This module works mechanically similar to lib/modal.fnl. (let [{: apps : app} state next-app (find (by-key app-name) apps)] - (log.df "TRANSITION: ->enter app %s prev %s next %s" app-name app next-app ) ;; DELETEME - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :launch-app-effect})) + (when next-app + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :launch-app-effect}))) (fn ->close [state action app-name] @@ -207,7 +206,6 @@ This module works mechanically similar to lib/modal.fnl. Kicks off an effect to bind app-specific keys Returns the old state " - (log.df "TRANSITION: ->close app app-name %s" app-name) ;; DELETEME {:state state :effect :close-app-effect}) @@ -267,7 +265,6 @@ Assign some simple keywords for each hs.application.watcher event type. Returns nil. Relies on side-effects. " (let [event-type (. app-events event)] - (log.df "Got watch-apps event %s" event-type) ;; DELETEME (if (= event-type :activated) (enter app-name) (= event-type :deactivated) @@ -310,7 +307,6 @@ Assign some simple keywords for each hs.application.watcher event type. Takes a transition record from the FSM. Returns nil. " - (log.df "PROXY action %s effect %s extra %s app %s" action effect extra (and next-state.context.app next-state.context.app.key)) ; DELETEME (emit action next-state.context.app)) @@ -352,13 +348,10 @@ Assign some simple keywords for each hs.application.watcher event type. Bind keys and lifecycle for the new current app. Return a cleanup function to cleanup these bindings. " - (when context.app - (lifecycle.activate-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME - (fn [] - (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME - (unbind-keys))))) + (lifecycle.activate-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (fn [] + (unbind-keys)))) (fn launch-app-effect [context] @@ -366,13 +359,10 @@ Assign some simple keywords for each hs.application.watcher event type. Bind keys and lifecycle for the next current app. Return a cleanup function to cleanup these bindings. " - (when context.app - (lifecycle.launch-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME - (fn [] - (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME - (unbind-keys))))) + (lifecycle.launch-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (fn [] + (unbind-keys)))) (fn my-effect-handler [effect-map] @@ -392,12 +382,10 @@ Assign some simple keywords for each hs.application.watcher event type. (fn [{: prev-state : next-state : action : effect : extra}] ;; Whenever a transition occurs, call the cleanup function for that ;; particular app, if set - (log.df "EFFECTS HANDLER for effect %s on app %s" effect extra) ;; DELETEME ;; Call the cleanup function for this app if it's set (call-when (. (atom.deref cleanup-ref) extra)) (let [cleanup-map (atom.deref cleanup-ref) effect-func (. effect-map effect)] - (log.df "Cleanup map: %s" (hs.inspect cleanup-map)) ;; DELETEME ;; Update the cleanup entry for this app with a new func or nil (atom.reset! cleanup-ref (merge cleanup-map @@ -406,17 +394,13 @@ Assign some simple keywords for each hs.application.watcher event type. (local apps-effect (my-effect-handler {:enter-app-effect (fn [state extra] - (log.df "EFFECT: enter-app") ;; DELETEME (enter-app-effect state.context)) :leave-app-effect (fn [state extra] - (log.df "EFFECT: leave-app") ;; DELETEME (lifecycle.deactivate-app state.context.app) nil) :launch-app-effect (fn [state extra] - (log.df "EFFECT: launch-app") ;; DELETEME (launch-app-effect state.context)) :close-app-effect (fn [state extra] - (log.df "EFFECT: close-app") ;; DELETEME (lifecycle.close-app state.context.app) nil)})) diff --git a/lib/modal.fnl b/lib/modal.fnl index 46dd23e..4db54a7 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -300,7 +300,6 @@ switching menus in one place which is then powered by config.fnl. menu (if (and app-menu (has-some? app-menu.items)) app-menu config)] - (log.df "TRANSITION: idle->active app-menu %s menu %s config %s" (and app-menu app-menu.key) menu config) ;; DELETEME {:state {:current-state :active :context (merge state.context {:menu menu :history (if state.history @@ -317,7 +316,6 @@ switching menus in one place which is then powered by config.fnl. Kicks off an effect to close the modal, stop the timeout, and unbind keys Returns updated modal state machine state table. " - (log.df "TRANSITION: active->idle") ;; DELETEME {:state {:current-state :idle :context (merge state.context {:menu :nil :history []})} @@ -333,7 +331,6 @@ switching menus in one place which is then powered by config.fnl. menu otherwise results in no operation Returns new modal state " - (log.df "TRANSITION: ->enter-app action %s extra %s" action extra) ;; DELETEME (let [{:config config :menu prev-menu} state.context app-menu (apps.get-app) @@ -356,7 +353,6 @@ switching menus in one place which is then powered by config.fnl. Takes the current modal state table. Returns new updated modal state if we are leaving the current app. " - (log.df "TRANSITION: active->leave-app") ;; DELETEME (let [{:config config :menu prev-menu} state.context] (if (= prev-menu.key config.key) @@ -376,7 +372,6 @@ switching menus in one place which is then powered by config.fnl. menu (if menu-key (find (by-key menu-key) prev-menu.items) config)] - (log.df "TRANSITION: active->submenu with menu-key %s menu %s" menu-key menu) ;; DELETEME {:state {:current-state :submenu :context (merge state.context {:menu menu})} :effect :open-submenu})) @@ -393,7 +388,6 @@ switching menus in one place which is then powered by config.fnl. Takes the current modal state table. Returns a the old state with a :stop-timeout added " - (log.df "TRANSITION: add-timeout-transition") ;; DELETEME {:state {:current-state state.current-state :context (merge state.context {:stop-timeout (timeout deactivate-modal)})} @@ -412,7 +406,6 @@ switching menus in one place which is then powered by config.fnl. :history hist :menu menu} state.context prev-menu (. hist (- (length hist) 1))] - (log.df "TRANSITION: submenu->previous") ;; DELETEME (if prev-menu {:state {:current-state :submenu :context (merge state.context {:menu prev-menu @@ -459,7 +452,6 @@ switching menus in one place which is then powered by config.fnl. fsm.state :log-state (fn log-state [state] - (log.df "state is now: %s" state.current-state) ;; DELETEME (when state.context.history (log.df (hs.inspect (map #(. $1 :title) state.context.history))))))) @@ -468,10 +460,8 @@ switching menus in one place which is then powered by config.fnl. (local modal-effect (statemachine.effect-handler {:show-modal-menu (fn [state extra] - (log.df "Effect: show modal") ;; DELETEME (show-modal-menu state.context)) :open-submenu (fn [state extra] - (log.df "Effect: Open submenu with extra %s" extra) ;; DELETEME (show-modal-menu state.context))})) (fn proxy-app-action @@ -484,7 +474,6 @@ switching menus in one place which is then powered by config.fnl. Executes a side-effect Returns nil " - (log.df "PROXY FROM APPS action %s data %s" action data) ; DELETEME (fsm.signal action data)) From 758ac82ec1938a1ce5d9fd2e573b25392658353e Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 12:26:08 -0400 Subject: [PATCH 36/48] wip: Port vim to new statemachine --- vim.fnl | 129 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 76 insertions(+), 53 deletions(-) diff --git a/vim.fnl b/vim.fnl index beba670..1154a0b 100644 --- a/vim.fnl +++ b/vim.fnl @@ -1,15 +1,16 @@ (local atom (require :lib.atom)) (local hyper (require :lib.hyper)) -(local {:call-when call-when - :contains? contains? - :eq? eq? - :filter filter - :find find - :get-in get-in - :has-some? has-some? - :map map - :some some} (require :lib.functional)) -(local machine (require :lib.statemachine)) +(local {: call-when + : contains? + : eq? + : filter + : find + : get-in + : has-some? + : map + : noop + : some} (require :lib.functional)) +(local statemachine (require :lib.statemachine)) (local {:bind-keys bind-keys} (require :lib.bind)) (local log (hs.logger.new "vim.fnl" "debug")) @@ -56,29 +57,27 @@ TODO: Create another state machine system to support key chords for bindings (fn disable [] (when fsm - (: box :hide) - (: text :hide) - (fsm.dispatch :disable))) + (fsm.signal :disable))) (fn enable [] (when fsm - (fsm.dispatch :enable))) + (fsm.signal :enable))) (fn normal [] (when fsm - (fsm.dispatch :normal))) + (fsm.signal :normal))) (fn visual [] (when fsm - (fsm.dispatch :visual))) + (fsm.signal :visual))) (fn insert [] (when fsm - (fsm.dispatch :insert))) + (fsm.signal :insert))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -290,24 +289,38 @@ TODO: Create another state machine system to support key chords for bindings ;; Side Effects ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(fn normal-mode - [state] +(fn enter-normal-mode + [state extra] + (log.df "enter-normal-mode effect") ;; DELETEME (state-box "Normal") - (call-when state.unbind-keys) - {:mode :normal - :unbind-keys (bind-keys bindings.normal) - }) + (bind-keys bindings.normal)) -(fn insert-mode - [] +(fn enter-insert-mode + [state extra] + (log.df "enter-insert-mode effect") ;; DELETEME (state-box "Insert") (bind-keys bindings.insert)) -(fn visual-mode - [] +(fn enter-visual-mode + [state extra] + (log.df "enter-visual-mode effect") ;; DELETEME (state-box "Visual") (bind-keys bindings.visual)) +(fn disable-vim-mode + [state extra] + (log.df "enter-disable-mode effect") ;; DELETEME + (: box :hide) + (: text :hide)) + +(local vim-effect + (statemachine.effect-handler + {:enter-normal-mode enter-normal-mode + :enter-insert-mode enter-insert-mode + :enter-visual-mode enter-visual-mode + :disable-vim-mode disable-vim-mode + })) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Transitions @@ -315,37 +328,46 @@ TODO: Create another state machine system to support key chords for bindings (fn disabled->normal [state data] - (when (get-in [:config :vim :enabled] state) - (normal-mode state))) + (log.df "disabled->normal") ;; DELETEME + (when (get-in [:context :config :vim :enabled] state) + {:state {:current-state :normal + :context state.context} + :effect :enter-normal-mode})) (fn normal->insert [state data] - (call-when state.unbind-keys) - (call-when state.untap) - {:mode :insert - :unbind-keys (insert-mode)}) + (log.df "normal->insert") ;; DELETEME + {:state {:current-state :insert + :context state.context} + :effect :enter-insert-mode}) (fn normal->visual [state data] - (call-when state.unbind-keys) - (call-when state.untap) - {:mode :visual - :unbind-keys (visual-mode)}) + (log.df "normal-visual") ;; DELETEME + {:state {:current-state :visual + :context state.context} + :effect :enter-visual-mode}) (fn ->disabled [state data] - (call-when state.unbind-keys) - (call-when state.untap) - {:mode :disabled - :unbind-keys :nil}) + (log.df "->disabled") ;; DELETEME + {:state {:current-state :disabled + :context state.context} + :effect :disable-vim-mode}) (fn insert->normal [state data] - (normal-mode state)) + (log.df "insert->normal") ;; DELETEME + {:state {:current-state :normal + :context state.context} + :effect :enter-normal-mode}) (fn visual->normal [state data] - (normal-mode state)) + (log.df "visual->normal") ;; DELETEME + {:state {:current-state :normal + :context state.context} + :effect :enter-normal-mode}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -371,7 +393,7 @@ TODO: Create another state machine system to support key chords for bindings [fsm] (atom.add-watch fsm.state :logger (fn [state] - (log.f "Vim mode: %s" state.mode)))) + (log.f "Vim mode: %s" state.current-state)))) (fn watch-screen [fsm active-screen-changed] @@ -407,13 +429,14 @@ TODO: Create another state machine system to support key chords for bindings screen. Returns function to cleanup watcher resources " - (let [initial {:config config - :mode :disabled - :unbind-keys nil} - state-machine (machine.new states initial :mode) + (let [template {:state {:current-state :disabled + :context {:config config}} + :states states} + _fsm (statemachine.new template) stop-screen-watcher (create-screen-watcher - (partial watch-screen state-machine))] - (set fsm state-machine) + (partial watch-screen _fsm))] + (set fsm _fsm) + (fsm.subscribe vim-effect) (log-updates fsm) (when (get-in [:vim :enabled] config) (enable)) @@ -425,6 +448,6 @@ TODO: Create another state machine system to support key chords for bindings ;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{:init init - :disable disable - :enable enable} +{: init + : disable + : enable} From f7354fb9c399424b54af46bfd5aee7feee8a0059 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 12:50:51 -0400 Subject: [PATCH 37/48] minor cleanup --- vim.fnl | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/vim.fnl b/vim.fnl index 1154a0b..9f5c88c 100644 --- a/vim.fnl +++ b/vim.fnl @@ -1,5 +1,4 @@ (local atom (require :lib.atom)) -(local hyper (require :lib.hyper)) (local {: call-when : contains? : eq? @@ -27,9 +26,6 @@ TODO: Create another state machine system to support key chords for bindings endlessly enter recursive submenus " -;; Debug -(local hyper (require :lib.hyper)) - (var fsm nil) ;; Box shapes for displaying current mode @@ -51,7 +47,7 @@ TODO: Create another state machine system to support key chords for bindings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Actions +;; Action signalers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn disable @@ -318,8 +314,7 @@ TODO: Create another state machine system to support key chords for bindings {:enter-normal-mode enter-normal-mode :enter-insert-mode enter-insert-mode :enter-visual-mode enter-visual-mode - :disable-vim-mode disable-vim-mode - })) + :disable-vim-mode disable-vim-mode})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 7628b47a8cca607c24c140e5156c848dfdebe951 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 12:50:58 -0400 Subject: [PATCH 38/48] cleanup in modal --- lib/modal.fnl | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/modal.fnl b/lib/modal.fnl index 4db54a7..fee38b8 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -247,22 +247,22 @@ switching menus in one place which is then powered by config.fnl. 99999))) (fn show-modal-menu - [context] + [state] " Main API to display a modal and run side-effects - Display the modal alert Takes current modal state from our modal statemachine Returns the function to cleanup everything it sets up " - (lifecycle.enter-menu context.menu) - (modal-alert context.menu) - (let [unbind-keys (bind-menu-keys context.menu.items) - stop-timeout context.stop-timeout] + (lifecycle.enter-menu state.context.menu) + (modal-alert state.context.menu) + (let [unbind-keys (bind-menu-keys state.context.menu.items) + stop-timeout state.context.stop-timeout] (fn [] (hs.alert.closeAll 0) (unbind-keys) (call-when stop-timeout) - (lifecycle.exit-menu context.menu) + (lifecycle.exit-menu state.context.menu) ))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -455,14 +455,10 @@ switching menus in one place which is then powered by config.fnl. (when state.context.history (log.df (hs.inspect (map #(. $1 :title) state.context.history))))))) -; TODO: Bind show-modal-menu direct -; TODO: Do we only need one effect? (local modal-effect (statemachine.effect-handler - {:show-modal-menu (fn [state extra] - (show-modal-menu state.context)) - :open-submenu (fn [state extra] - (show-modal-menu state.context))})) + {:show-modal-menu show-modal-menu + :open-submenu show-modal-menu})) (fn proxy-app-action [[action data]] From 32c6b158cbfb61b6be6f7d0ba08d5cd906fc915e Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 8 Oct 2021 08:52:08 -0400 Subject: [PATCH 39/48] apps: Update app even when new app isn't in config We want to do this so that once we leave an app the state of the machine has :app set to nil, otherwise the modal menu will continue to display items from the previous app. Because we are still firing an effect, we need to guard around the effect handlers, doing nothing when we have an enter or launch effect but no app. --- lib/apps.fnl | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index 5f5b2b0..7229000 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -152,12 +152,11 @@ This module works mechanically similar to lib/modal.fnl. (let [{: apps : app} state.context next-app (find (by-key app-name) apps)] - (when next-app - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :enter-app-effect}))) + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :enter-app-effect})) (fn in-app->leave @@ -188,12 +187,11 @@ This module works mechanically similar to lib/modal.fnl. (let [{: apps : app} state next-app (find (by-key app-name) apps)] - (when next-app - {:state {:current-state :in-app - :context {:apps apps - :app next-app - :prev-app app}} - :effect :launch-app-effect}))) + {:state {:current-state :in-app + :context {:apps apps + :app next-app + :prev-app app}} + :effect :launch-app-effect})) (fn ->close [state action app-name] @@ -348,10 +346,13 @@ Assign some simple keywords for each hs.application.watcher event type. Bind keys and lifecycle for the new current app. Return a cleanup function to cleanup these bindings. " - (lifecycle.activate-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (fn [] - (unbind-keys)))) + (when context.app + (lifecycle.activate-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys))))) (fn launch-app-effect [context] @@ -359,10 +360,13 @@ Assign some simple keywords for each hs.application.watcher event type. Bind keys and lifecycle for the next current app. Return a cleanup function to cleanup these bindings. " - (lifecycle.launch-app context.app) - (let [unbind-keys (bind-app-keys context.app.keys)] - (fn [] - (unbind-keys)))) + (when context.app + (lifecycle.launch-app context.app) + (let [unbind-keys (bind-app-keys context.app.keys)] + (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME + (fn [] + (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME + (unbind-keys))))) (fn my-effect-handler [effect-map] From 8b86f261bd06fe4cf2ff23e654904ae2d2b55985 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 14:46:48 -0400 Subject: [PATCH 40/48] apps: close & leave effects have to operate on prev app --- lib/apps.fnl | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index 7229000..1a59fa1 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -397,16 +397,24 @@ Assign some simple keywords for each hs.application.watcher event type. (local apps-effect (my-effect-handler - {:enter-app-effect (fn [state extra] - (enter-app-effect state.context)) - :leave-app-effect (fn [state extra] - (lifecycle.deactivate-app state.context.app) - nil) - :launch-app-effect (fn [state extra] - (launch-app-effect state.context)) - :close-app-effect (fn [state extra] - (lifecycle.close-app state.context.app) - nil)})) + {:enter-app-effect (fn [state extra] + (log.df "EFFECT: enter-app") ;; DELETEME + (enter-app-effect state.context)) + :leave-app-effect (fn [state extra] + (let [app (find (by-key extra) state.context.apps)] + (when app + (log.df "EFFECT: leave-app extra %s" extra) ;; DELETEME + (lifecycle.deactivate-app app))) + nil) + :launch-app-effect (fn [state extra] + (log.df "EFFECT: launch-app") ;; DELETEME + (launch-app-effect state.context)) + :close-app-effect (fn [state extra] + (let [app (find (by-key extra) state.context.apps)] + (when app + (log.df "EFFECT: close-app extra %s" extra) ;; DELETEME + (lifecycle.deactivate-app app))) + nil)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 0883a7f132c267db7244af295b66df470b892e2b Mon Sep 17 00:00:00 2001 From: Grazfather Date: Sun, 10 Oct 2021 17:47:04 -0400 Subject: [PATCH 41/48] fix bug in watch-screen --- lib/apps.fnl | 34 ++++++++++++++++------------------ vim.fnl | 4 ++-- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index 1a59fa1..6bd8938 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -397,24 +397,22 @@ Assign some simple keywords for each hs.application.watcher event type. (local apps-effect (my-effect-handler - {:enter-app-effect (fn [state extra] - (log.df "EFFECT: enter-app") ;; DELETEME - (enter-app-effect state.context)) - :leave-app-effect (fn [state extra] - (let [app (find (by-key extra) state.context.apps)] - (when app - (log.df "EFFECT: leave-app extra %s" extra) ;; DELETEME - (lifecycle.deactivate-app app))) - nil) - :launch-app-effect (fn [state extra] - (log.df "EFFECT: launch-app") ;; DELETEME - (launch-app-effect state.context)) - :close-app-effect (fn [state extra] - (let [app (find (by-key extra) state.context.apps)] - (when app - (log.df "EFFECT: close-app extra %s" extra) ;; DELETEME - (lifecycle.deactivate-app app))) - nil)})) + {:enter-app-effect (fn [state extra] + (log.df "EFFECT: enter-app") ;; DELETEME + (enter-app-effect state.context)) + :leave-app-effect (fn [state extra] + (when state.context.prev-app + (log.df "EFFECT: leave-app extra %s" extra) ;; DELETEME + (lifecycle.deactivate-app state.context.prev-app)) + nil) + :launch-app-effect (fn [state extra] + (log.df "EFFECT: launch-app") ;; DELETEME + (launch-app-effect state.context)) + :close-app-effect (fn [state extra] + (when state.context.prev-app + (log.df "EFFECT: close-app") ;; DELETEME + (lifecycle.close-app state.context.prev-app)) + nil)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/vim.fnl b/vim.fnl index 9f5c88c..ac0fea2 100644 --- a/vim.fnl +++ b/vim.fnl @@ -393,8 +393,8 @@ TODO: Create another state machine system to support key chords for bindings (fn watch-screen [fsm active-screen-changed] (let [state (atom.deref fsm.state)] - (when (~= state.mode :disabled) - (state-box state.mode)))) + (when (~= state.current-state :disabled) + (state-box state.current-state)))) ;; (fn log-key ;; [event] From 89018c8026c4d38b2e47d64603196fde68b3ede0 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Tue, 12 Oct 2021 14:24:44 -0400 Subject: [PATCH 42/48] statemachine: Rename 'signal' to 'send' --- lib/apps.fnl | 10 +++++----- lib/modal.fnl | 14 +++++++------- lib/statemachine.fnl | 14 +++++++------- vim.fnl | 12 ++++++------ 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index 6bd8938..b3d8779 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -59,7 +59,7 @@ This module works mechanically similar to lib/modal.fnl. (atom.swap! actions (fn [] [action data]))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Action signalers +;; Action senders ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn enter @@ -72,7 +72,7 @@ This module works mechanically similar to lib/modal.fnl. Transitions to the entered finite-state-machine state. Returns nil. " - (fsm.signal :enter-app app-name)) + (fsm.send :enter-app app-name)) (fn leave [app-name] @@ -82,7 +82,7 @@ This module works mechanically similar to lib/modal.fnl. Transition the state machine to idle from active app state. Returns nil. " - (fsm.signal :leave-app app-name)) + (fsm.send :leave-app app-name)) (fn launch [app-name] @@ -92,7 +92,7 @@ This module works mechanically similar to lib/modal.fnl. Calls the launch lifecycle method defined for an app in config.fnl Returns nil. " - (fsm.signal :launch-app app-name)) + (fsm.send :launch-app app-name)) (fn close [app-name] @@ -102,7 +102,7 @@ This module works mechanically similar to lib/modal.fnl. Calls the exit lifecycle method defined for an app in config.fnl Returns nil. " - (fsm.signal :close-app app-name)) + (fsm.send :close-app app-name)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/lib/modal.fnl b/lib/modal.fnl index fee38b8..43a9d6d 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -3,7 +3,7 @@ Displays the menu modals, sub-menus, and application-specific modals if set in config.fnl. We define a state machine, which uses our local states to determine states, and -transitions. Then we can signal events that may transition between specific +transitions. Then we can send actions that may transition between specific states defined in the table. Allows us to create the machinery for displaying, entering, exiting, and @@ -54,7 +54,7 @@ switching menus in one place which is then powered by config.fnl. nil)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Event Dispatchers +;; Action senders ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn activate-modal @@ -68,7 +68,7 @@ switching menus in one place which is then powered by config.fnl. specific menu key. Side effectful " - (fsm.signal :activate menu-key)) + (fsm.send :activate menu-key)) (fn deactivate-modal @@ -78,7 +78,7 @@ switching menus in one place which is then powered by config.fnl. Takes no arguments. Side effectful " - (fsm.signal :deactivate)) + (fsm.send :deactivate)) (fn previous-modal @@ -87,7 +87,7 @@ switching menus in one place which is then powered by config.fnl. API to transition to the previous modal in our history. Useful for returning to the main menu when in the window modal for instance. " - (fsm.signal :previous)) + (fsm.send :previous)) (fn start-modal-timeout @@ -101,7 +101,7 @@ switching menus in one place which is then powered by config.fnl. Takes no arguments. Side effectful " - (fsm.signal :start-timeout)) + (fsm.send :start-timeout)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -470,7 +470,7 @@ switching menus in one place which is then powered by config.fnl. Executes a side-effect Returns nil " - (fsm.signal action data)) + (fsm.send action data)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/lib/statemachine.fnl b/lib/statemachine.fnl index 0bd3ef1..1431fd1 100644 --- a/lib/statemachine.fnl +++ b/lib/statemachine.fnl @@ -19,8 +19,8 @@ The 'new' function takes a template, which is a table with the following schema: :state and the :effect. * The :state contains a (potentially changed) :current-state and a new :context, which is updated in the state machine. -* Functions can subscribe to all signals, and are provided a TRANSITION RECORD, - which contains: +* Functions can subscribe to all transitions, and are provided a TRANSITION + RECORD, which contains: * :prev-state * :next-state * :action @@ -63,7 +63,7 @@ the next transition. [fsm] (atom.deref fsm.state)) -(fn signal +(fn send [fsm action extra] " Based on the action and the fsm's current-state, set the new state and call @@ -73,9 +73,9 @@ the next transition. {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] (let [ - _ (log.df "SIGNAL: Calling tx fn from state %s for action %s" current-state action) ;; DELETEME + _ (log.df "SEND Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) - ;; _ (log.df "SIGNAL: transition object %s" (hs.inspect transition)) ;; DELETEME + ;; _ (log.df "SEND transition object %s" (hs.inspect transition)) ;; DELETEME new-state (if transition transition.state state) effect (if transition transition.effect nil)] @@ -131,7 +131,7 @@ the next transition. :log (if template.log (hs.logger.new template.log "info"))}] ; Add methods (tset fsm :get-state (partial get-state fsm)) - (tset fsm :signal (partial signal fsm)) + (tset fsm :send (partial send fsm)) (tset fsm :subscribe (partial subscribe fsm)) fsm)) @@ -140,6 +140,6 @@ the next transition. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; {: effect-handler - : signal + : send : subscribe :new create-machine} diff --git a/vim.fnl b/vim.fnl index ac0fea2..873c9ec 100644 --- a/vim.fnl +++ b/vim.fnl @@ -47,33 +47,33 @@ TODO: Create another state machine system to support key chords for bindings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Action signalers +;; Action senders ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn disable [] (when fsm - (fsm.signal :disable))) + (fsm.send :disable))) (fn enable [] (when fsm - (fsm.signal :enable))) + (fsm.send :enable))) (fn normal [] (when fsm - (fsm.signal :normal))) + (fsm.send :normal))) (fn visual [] (when fsm - (fsm.signal :visual))) + (fsm.send :visual))) (fn insert [] (when fsm - (fsm.signal :insert))) + (fsm.send :insert))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 65c0f4e1e57107441b294e1a38a6f5f3c3e38e4f Mon Sep 17 00:00:00 2001 From: Grazfather Date: Tue, 12 Oct 2021 14:26:13 -0400 Subject: [PATCH 43/48] Remove all debug prints --- lib/apps.fnl | 8 -------- lib/statemachine.fnl | 4 ---- vim.fnl | 10 ---------- 3 files changed, 22 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index b3d8779..552d565 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -349,9 +349,7 @@ Assign some simple keywords for each hs.application.watcher event type. (when context.app (lifecycle.activate-app context.app) (let [unbind-keys (bind-app-keys context.app.keys)] - (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME (fn [] - (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME (unbind-keys))))) (fn launch-app-effect @@ -363,9 +361,7 @@ Assign some simple keywords for each hs.application.watcher event type. (when context.app (lifecycle.launch-app context.app) (let [unbind-keys (bind-app-keys context.app.keys)] - (log.df "Returning cleanup for %s" context.app.key) ;; DELETEME (fn [] - (log.df "Calling unbind keys for %s" context.app.key) ;; DELETEME (unbind-keys))))) (fn my-effect-handler @@ -398,19 +394,15 @@ Assign some simple keywords for each hs.application.watcher event type. (local apps-effect (my-effect-handler {:enter-app-effect (fn [state extra] - (log.df "EFFECT: enter-app") ;; DELETEME (enter-app-effect state.context)) :leave-app-effect (fn [state extra] (when state.context.prev-app - (log.df "EFFECT: leave-app extra %s" extra) ;; DELETEME (lifecycle.deactivate-app state.context.prev-app)) nil) :launch-app-effect (fn [state extra] - (log.df "EFFECT: launch-app") ;; DELETEME (launch-app-effect state.context)) :close-app-effect (fn [state extra] (when state.context.prev-app - (log.df "EFFECT: close-app") ;; DELETEME (lifecycle.close-app state.context.prev-app)) nil)})) diff --git a/lib/statemachine.fnl b/lib/statemachine.fnl index 1431fd1..e5a971e 100644 --- a/lib/statemachine.fnl +++ b/lib/statemachine.fnl @@ -48,8 +48,6 @@ the next transition. : merge : slice} (require :lib.functional)) -(local log (hs.logger.new "statemachine.fnl" "debug")) ;; DELETEME - ;; DELETEME (fn update-state [fsm state] @@ -73,9 +71,7 @@ the next transition. {: current-state : context} state] (if-let [tx-fn (get-transition-function fsm current-state action)] (let [ - _ (log.df "SEND Calling tx fn from state %s for action %s" current-state action) ;; DELETEME transition (tx-fn state action extra) - ;; _ (log.df "SEND transition object %s" (hs.inspect transition)) ;; DELETEME new-state (if transition transition.state state) effect (if transition transition.effect nil)] diff --git a/vim.fnl b/vim.fnl index 873c9ec..948bba4 100644 --- a/vim.fnl +++ b/vim.fnl @@ -287,25 +287,21 @@ TODO: Create another state machine system to support key chords for bindings (fn enter-normal-mode [state extra] - (log.df "enter-normal-mode effect") ;; DELETEME (state-box "Normal") (bind-keys bindings.normal)) (fn enter-insert-mode [state extra] - (log.df "enter-insert-mode effect") ;; DELETEME (state-box "Insert") (bind-keys bindings.insert)) (fn enter-visual-mode [state extra] - (log.df "enter-visual-mode effect") ;; DELETEME (state-box "Visual") (bind-keys bindings.visual)) (fn disable-vim-mode [state extra] - (log.df "enter-disable-mode effect") ;; DELETEME (: box :hide) (: text :hide)) @@ -323,7 +319,6 @@ TODO: Create another state machine system to support key chords for bindings (fn disabled->normal [state data] - (log.df "disabled->normal") ;; DELETEME (when (get-in [:context :config :vim :enabled] state) {:state {:current-state :normal :context state.context} @@ -331,35 +326,30 @@ TODO: Create another state machine system to support key chords for bindings (fn normal->insert [state data] - (log.df "normal->insert") ;; DELETEME {:state {:current-state :insert :context state.context} :effect :enter-insert-mode}) (fn normal->visual [state data] - (log.df "normal-visual") ;; DELETEME {:state {:current-state :visual :context state.context} :effect :enter-visual-mode}) (fn ->disabled [state data] - (log.df "->disabled") ;; DELETEME {:state {:current-state :disabled :context state.context} :effect :disable-vim-mode}) (fn insert->normal [state data] - (log.df "insert->normal") ;; DELETEME {:state {:current-state :normal :context state.context} :effect :enter-normal-mode}) (fn visual->normal [state data] - (log.df "visual->normal") ;; DELETEME {:state {:current-state :normal :context state.context} :effect :enter-normal-mode}) From fe694277183f0be37d100936b0d59884492e6faa Mon Sep 17 00:00:00 2001 From: Grazfather Date: Tue, 12 Oct 2021 14:27:48 -0400 Subject: [PATCH 44/48] Remove comment --- lib/apps.fnl | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index 552d565..17c782d 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -380,8 +380,6 @@ Assign some simple keywords for each hs.application.watcher event type. (let [cleanup-ref (atom.new {})] ;; Return a subscriber function (fn [{: prev-state : next-state : action : effect : extra}] - ;; Whenever a transition occurs, call the cleanup function for that - ;; particular app, if set ;; Call the cleanup function for this app if it's set (call-when (. (atom.deref cleanup-ref) extra)) (let [cleanup-map (atom.deref cleanup-ref) From 2b8e11b200230f3a2102b47fad5171d679b0c33b Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 15 Oct 2021 09:55:32 -0400 Subject: [PATCH 45/48] Better heading --- lib/apps.fnl | 2 +- lib/modal.fnl | 2 +- vim.fnl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index 17c782d..d5fe3d2 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -59,7 +59,7 @@ This module works mechanically similar to lib/modal.fnl. (atom.swap! actions (fn [] [action data]))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Action senders +;; Action dispatch functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn enter diff --git a/lib/modal.fnl b/lib/modal.fnl index 43a9d6d..e97c2fc 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -54,7 +54,7 @@ switching menus in one place which is then powered by config.fnl. nil)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Action senders +;; Action dispatch functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn activate-modal diff --git a/vim.fnl b/vim.fnl index 948bba4..4caa5db 100644 --- a/vim.fnl +++ b/vim.fnl @@ -47,7 +47,7 @@ TODO: Create another state machine system to support key chords for bindings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Action senders +;; Action dispatch functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn disable From 4f2470c3f79607d4d78ee90fb727ad40f1e3c7ac Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 15 Oct 2021 09:55:41 -0400 Subject: [PATCH 46/48] Better function names --- lib/apps.fnl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index d5fe3d2..25478cc 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -295,7 +295,7 @@ Assign some simple keywords for each hs.application.watcher event type. [state] (log.df "app is now: %s" (and state.context.app state.context.app.key))))) -(fn action-watcher +(fn watch-actions [{: prev-state : next-state : action : effect : extra}] " Internal API function to emit app-specific state machine events and transitions to @@ -364,7 +364,7 @@ Assign some simple keywords for each hs.application.watcher event type. (fn [] (unbind-keys))))) -(fn my-effect-handler +(fn app-effect-handler [effect-map] " Takes a map of effect->function and returns a function that handles these @@ -390,7 +390,7 @@ Assign some simple keywords for each hs.application.watcher event type. {extra (call-when effect-func next-state extra)})))))) (local apps-effect - (my-effect-handler + (app-effect-handler {:enter-app-effect (fn [state extra] (enter-app-effect state.context)) :leave-app-effect (fn [state extra] @@ -428,7 +428,7 @@ Assign some simple keywords for each hs.application.watcher event type. (set fsm (statemachine.new template)) (fsm.subscribe apps-effect) (start-logger fsm) - (fsm.subscribe action-watcher) + (fsm.subscribe watch-actions) (enter active-app) (: app-watcher :start) (fn cleanup [] From 9784eedf3e9ecaa72fd6de045926c225a8995fdb Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 15 Oct 2021 10:01:48 -0400 Subject: [PATCH 47/48] s/signal/send in statemachine test --- test/statemachine-test.fnl | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/statemachine-test.fnl b/test/statemachine-test.fnl index 1d838e7..fc08f50 100644 --- a/test/statemachine-test.fnl +++ b/test/statemachine-test.fnl @@ -34,34 +34,34 @@ (fn [] (let [fsm (make-fsm)] (is.eq? (type fsm.get-state) :function "No get-state method") - (is.eq? (type fsm.signal) :function "No signal method ") + (is.eq? (type fsm.send) :function "No send method ") (is.eq? (type fsm.subscribe) :function "No subscribe method")))) (it "Should transition to opened on toggle action" (fn [] (let [fsm (make-fsm)] - (is.eq? (fsm.signal :toggle) true "Dispatch did not return true for handled event") + (is.eq? (fsm.send :toggle) true "Dispatch did not return true for handled event") (is.eq? (. (atom.deref fsm.state) :current-state) :opened "State did not transition to opened")))) (it "Should transition from closed -> opened -> closed" (fn [] (let [fsm (make-fsm)] - (fsm.signal :toggle) - (fsm.signal :toggle) + (fsm.send :toggle) + (fsm.send :toggle) (is.eq? (. (atom.deref fsm.state) :current-state) :closed "State did not transition back to closed") (is.eq? (. (atom.deref fsm.state) :context :i) 2 "context.i should be 2 from 2 transitions")))) (it "Should not explode when dispatching an unhandled event" (fn [] (let [fsm (make-fsm)] - (is.eq? (fsm.signal :fail nil) false "The FSM exploded from dispatching a :fail event")))) + (is.eq? (fsm.send :fail nil) false "The FSM exploded from dispatching a :fail event")))) (it "Subscribers should be called on events" (fn [] (let [fsm (make-fsm) i (atom.new 0)] (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1))))) - (fsm.signal :toggle) + (fsm.send :toggle) (is.eq? (atom.deref i) 1 "The subscriber was not called")))) (it "Subscribers should be provided old and new context, action, effect, and extra" @@ -78,7 +78,7 @@ (atom.swap! _action (fn [_ nv] nv) action) (atom.swap! _effect (fn [_ nv] nv) effect) (atom.swap! _extra (fn [_ nv] nv) extra))) - (fsm.signal :toggle :extra) + (fsm.send :toggle :extra) (is.not-eq? (. (atom.deref _old) :context :i) (. (atom.deref _new) :context :i) "Subscriber did not get old and new state") (is.eq? (atom.deref _action) :toggle "Subscriber did not get correct action") @@ -90,9 +90,9 @@ (let [fsm (make-fsm)] (let [i (atom.new 0) unsub (fsm.subscribe (fn [] (atom.swap! i (fn [v] (+ v 1)))))] - (fsm.signal :toggle) + (fsm.send :toggle) (unsub) - (fsm.signal :toggle) + (fsm.send :toggle) (is.eq? (atom.deref i) 1 "The subscriber was called after unsubscribing"))))) (it "Effect handler should maintain cleanup function" @@ -108,8 +108,8 @@ (atom.swap! effect-state (fn [_ nv] nv) :cleaned)))}) unsub (fsm.subscribe effect-handler)] - (fsm.signal :toggle) + (fsm.send :toggle) (is.eq? (atom.deref effect-state) :opened "Effect handler should have been called") - (fsm.signal :toggle) + (fsm.send :toggle) (is.eq? (atom.deref effect-state) :cleaned "Cleanup function should have been called") ))))) From 53a4cbc43349f7a0970551ac07a0938e56724d92 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Fri, 15 Oct 2021 10:04:49 -0400 Subject: [PATCH 48/48] statemachine test: assert in sub func --- test/statemachine-test.fnl | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/test/statemachine-test.fnl b/test/statemachine-test.fnl index fc08f50..be9a1a0 100644 --- a/test/statemachine-test.fnl +++ b/test/statemachine-test.fnl @@ -66,24 +66,14 @@ (it "Subscribers should be provided old and new context, action, effect, and extra" (fn [] - (let [fsm (make-fsm) - _old (atom.new nil) - _new (atom.new nil) - _action (atom.new nil) - _effect (atom.new nil) - _extra (atom.new nil)] + (let [fsm (make-fsm)] (fsm.subscribe (fn [{: prev-state : next-state : action : effect : extra}] - (atom.swap! _old (fn [_ nv] nv) prev-state) - (atom.swap! _new (fn [_ nv] nv) next-state) - (atom.swap! _action (fn [_ nv] nv) action) - (atom.swap! _effect (fn [_ nv] nv) effect) - (atom.swap! _extra (fn [_ nv] nv) extra))) - (fsm.send :toggle :extra) - (is.not-eq? (. (atom.deref _old) :context :i) - (. (atom.deref _new) :context :i) "Subscriber did not get old and new state") - (is.eq? (atom.deref _action) :toggle "Subscriber did not get correct action") - (is.eq? (atom.deref _effect) :opening "Subscriber did not get correct effect") - (is.eq? (atom.deref _extra) :extra "Subscriber did not get correct extra")))) + (is.not-eq? prev-state.context.i + next-state.context.i "Subscriber did not get old and new state") + (is.eq? action :toggle "Subscriber did not get correct action") + (is.eq? effect :opening "Subscriber did not get correct effect") + (is.eq? extra :extra "Subscriber did not get correct extra"))) + (fsm.send :toggle :extra)))) (it "Subscribers should be able to unsubscribe" (fn []