From 1426205fb4cc6fb7a1b18750dc432f272ea8ecc0 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Tue, 25 Jun 2019 01:31:46 -0700 Subject: [PATCH 01/34] Emacs improvements - run-emacs-fn - full-screen - vertical-split-with-emacs --- CHANGELOG.ORG | 5 +++++ emacs.fnl | 58 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.ORG b/CHANGELOG.ORG index 85f748c..dbac8c8 100644 --- a/CHANGELOG.ORG +++ b/CHANGELOG.ORG @@ -19,3 +19,8 @@ - [2019-06-23 Sun] - Auxiliary Emacs package, spacehammer.el - Fixes Local app-keys are leaking #15 +- [2019-06-25 Tue] + - Emacs improvements + + run-emacs-fn + + full-screen + + vertical-split-with-emacs diff --git a/emacs.fnl b/emacs.fnl index 4721e03..7466c2e 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -44,6 +44,49 @@ ;; global keybinging to invoke edit-with-emacs feature (local edit-with-emacs-key (hs.hotkey.new [:cmd :ctrl] :o nil edit-with-emacs)) +(fn run-emacs-fn + ;; executes given elisp function via emacsclient, if args table present passes + ;; them to the function + [elisp-fn args] + (let [args-lst (when args (.. " '" (table.concat args " '"))) + run-str (.. "/usr/local/bin/emacsclient" + " -e \"(funcall '" elisp-fn + (if args-lst args-lst "") + ")\"")] + (io.popen run-str))) + +(fn full-screen + ;; Switch to Emacs and expand its frame to fullscreen + [] + (hs.application.launchOrFocus :Emacs) + (run-emacs-fn "spacemacs/toggle-fullscreen-frame-on")) + +(fn vertical-split-with-emacs + ;; creates a vertical split with the current app and Emacs, with Emacs on the + ;; left and the app window on the right + [] + (let [windows (require :windows) + cur-app (-?> (hs.window.focusedWindow) (: :application) (: :name)) + rect-left [0 0 .5 1] + rect-right [.5 0 .5 1] + elisp (.. "(lambda ()" + " (spacemacs/toggle-fullscreen-frame-off) " + " (spacemacs/maximize-horizontally) " + " (spacemacs/maximize-vertically))")] + (run-emacs-fn elisp) + (hs.timer.doAfter + .2 + (fn [] + (if (= cur-app :Emacs) + (do + (windows.rect rect-left) + (windows.jump-to-last-window) + (windows.rect rect-right)) + (do + (windows.rect rect-right) + (hs.application.launchOrFocus :Emacs) + (windows.rect rect-left))))))) + (fn bind [hotkeyModal fsm] (: hotkeyModal :bind nil :c (fn [] (: fsm :toIdle) @@ -51,7 +94,13 @@ (: hotkeyModal :bind nil :z (fn [] (: fsm :toIdle) ;; note on currently clocked in - (capture true)))) + (capture true))) + (: hotkeyModal :bind nil :v (fn [] + (: fsm :toIdle) + (vertical-split-with-emacs))) + (: hotkeyModal :bind nil :f (fn [] + (: fsm :toIdle) + (full-screen)))) ;; adds Emacs modal state to the FSM instance (fn add-state [modal] @@ -60,8 +109,7 @@ {:from :* :init (fn [self fsm] (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text "c \tcapture\nz\tnote") - + (modal.display-modal-text "c \tcapture\nz\tnote\nf\tfullscreen\nv\tsplit") (bind self.hotkeyModal fsm) (: self.hotkeyModal :enter))})) @@ -97,9 +145,9 @@ (fn [] (hs.timer.doAfter 1.5 (fn [] - (let [app (hs.application.find :Emacs) + (let [app (hs.application.find :Emacs) windows (require :windows) - modal (require :modal)] + modal (require :modal)] (when app (: app :activate) (windows.maximize-window-frame (: modal :machine)))))))}))) From b540e0e839a8ff8a31687c77657171938e7855b1 Mon Sep 17 00:00:00 2001 From: Jay Zawrotny Date: Wed, 26 Jun 2019 01:14:15 -0400 Subject: [PATCH 02/34] Refactored spacehammer internals --- .gitignore | 1 + CHANGELOG.ORG | 8 + README.ORG | 96 +++++++- apps.fnl | 55 ++--- chrome.fnl | 70 ++---- config.fnl | 450 +++++++++++++++++++++++++++++++++++ core.fnl | 155 +++++++----- docs/spacehammer-fsm-0.1.png | Bin 0 -> 113712 bytes docs/spacehammer-fsm.graffle | Bin 0 -> 9595 bytes emacs.fnl | 92 +++---- grammarly.fnl | 26 +- keybindings.fnl | 180 -------------- lib/apps.fnl | 263 ++++++++++++++++++++ lib/atom.fnl | 47 ++++ lib/bind.fnl | 85 +++++++ lib/functional.fnl | 209 ++++++++++++++++ lib/hyper.fnl | 72 ++++++ lib/lifecycle.fnl | 50 ++++ lib/macros.fnl | 8 + lib/modal.fnl | 355 +++++++++++++++++++++++++++ lib/statemachine.fnl | 123 ++++++++++ lib/text.fnl | 30 +++ utils.lua => lib/utils.lua | 0 modal.fnl | 121 ---------- multimedia.fnl | 52 ++-- slack.fnl | 164 +++++++------ statemachine.lua | 156 ------------ vim.fnl | 408 +++++++++++++++++++++++++++++++ windows.fnl | 409 +++++++++++++++++++------------ 29 files changed, 2753 insertions(+), 932 deletions(-) create mode 100644 .gitignore create mode 100644 config.fnl create mode 100644 docs/spacehammer-fsm-0.1.png create mode 100644 docs/spacehammer-fsm.graffle delete mode 100644 keybindings.fnl create mode 100644 lib/apps.fnl create mode 100644 lib/atom.fnl create mode 100644 lib/bind.fnl create mode 100644 lib/functional.fnl create mode 100644 lib/hyper.fnl create mode 100644 lib/lifecycle.fnl create mode 100644 lib/macros.fnl create mode 100644 lib/modal.fnl create mode 100644 lib/statemachine.fnl create mode 100644 lib/text.fnl rename utils.lua => lib/utils.lua (100%) delete mode 100644 modal.fnl delete mode 100644 statemachine.lua create mode 100644 vim.fnl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8c6761 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +private \ No newline at end of file diff --git a/CHANGELOG.ORG b/CHANGELOG.ORG index dbac8c8..f79d8a5 100644 --- a/CHANGELOG.ORG +++ b/CHANGELOG.ORG @@ -24,3 +24,11 @@ + run-emacs-fn + full-screen + vertical-split-with-emacs +- [2019-07-19 Fri] + - Refactored… + + Modals + + Configuration + + Keybindings + + App specific keybindings + + App specific modals + + Vim mode diff --git a/README.ORG b/README.ORG index 37a1404..1336719 100644 --- a/README.ORG +++ b/README.ORG @@ -76,9 +76,9 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon **** Simple tab switcher for Chrome and iTerm =Cmd j/k= =Cmd l= in Chrome is re-mapped to =Cmd+Shift l= **** Simple vi-mode - - =Alt h/j/k/l= - simple left/right/up/down - - =Alt+Shift h/j/k/l= - word wise left/right/up/down - - =Alt+Ctrl+Shift h/j/k/l= - selecting things + - =h/j/k/l= - simple left/right/up/down + - =w/b= - word wise forward back + - =Shift h/j/k/l= - selecting things These can be disabled in certain apps (by default they they are ignored in Emacs) **** Slack Desktop Client enhancements @@ -88,11 +88,97 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon - Adding emoji to the last message - =Cmd-r= (sorry, but default =Cmd-Shift+\= is horribly inconvenient) - =C-o/C-i= - jumping back and forth in history ** TODO + - [ ] Chord function to better support keys like =jk= =fd= or =gg= - [ ] =jk= or =fd= to exit modals (like =evil-escape-key-sequence= in Emacs) - - [ ] window configuration profiles (similar to Layouts feature in Spacemacs) + - [ ] Window configuration profiles (similar to Layouts feature in Spacemacs) - [ ] Disable non-available keys in a modal. Keys that not listed should be simply ignored see #1 - [ ] Another thing I want is to be able to toggle ChromeDevtools panel - this is somewhat tricky, see [[https://github.com/Hammerspoon/hammerspoon/issues/1506][this issue]] - [ ] Better than default HUD display (something less obtrusive than ~hs.alert~ would be nice ** Customizing - That is pretty straightforward. Both, Fennel and Lua are extremely simple languages. I shamelessly borrowed this [[https://github.com/kyleconroy/lua-state-machine][state-machine implementation]] (why write from scratch?). Adding new modals, or app specific keys and app specific modals is quite simple, reach out if you have any questions. Thanks! +*** Update menus, menu items, bindings, and app specific features + All menu, app, and key bindings are defined in config.fnl. + You may edit this file directly but may run into conflicts with upstream changes. + Alternatively you can create a =~/.hammerspoon/private/config.fnl= that can by symlinked and tracked in your dotfiles. + The =~/.hammerspoon/private= directory is not source tracked so your customizations will be safe from upstream updates. +**** Modal Menu Items + Menu items are listed when you press =cmd+space= and can be nested. + Items map a key binding to an action, either a function or ="module:function-name"= string. + + Menu items may either define an action or a table list of items. + + + For menu items that should be repeated, add =repeatable: true= to the item table. + The repeatable flag keeps the menu option after the action has been triggered. + Repeating a menu item is ideal for actions like window layouts where you may wish to move the window from the left third to the right third. + + #+BEGIN_SRC fennel + (local launch-alfred {:title "Alfred" + :key :SPACE + :action (fn [] (hs.appplication.launchOrFocus "Alfred"))}) + (local slack-jump {:title "Slack" + :key :s + :action "slack:quick-switcher"}) + (local window-inc {:title "Window Halves" + :mods [:cmd] + :key :l + :action "windows:resize-inc-right"}) + (local submenu {:title "Submenu" + :key :t + :items [{:key :m + :title "Show a message" + :action (fn [] (alert "I'm a submenu action"))}]}) + (local config {:items [launch-alfred + slack-jump + window-inc + submenu]}) + #+END_SRC + +***** Lifecycle methods + Menu items may also define =:enter= and =:exit= functions or action strings. The parent menu item will call the =enter= function when it is opened and =exit= when it is closed. This may be used to manage more complex, or dynamic menus. +**** Global keys + Global keys are used to set up universal hot-keys for the actions you specify. + Unlike menu items they do not require a title attribute. + Additionally you may specify =repeat: true= to repeat the action while the key is held down. + + If you place =:hyper= as a mod, it will use a hyper mode that can be configured by the =hyper= config attribute. + This can be used to help create bindings that wont interfere with other apps. + For instance you may make your hyper trigger the virtual =:F18= and use a program like [[https://github.com/tekezo/Karabiner-Elements][karabiner-elements]] to map caps-lock to =F18=. + + #+BEGIN_SRC fennel + (local config {:hyper {:mods [:cmd :ctrl :alt :shift]} + :keys [{:mods [:cmd] + :key :space + :action "lib.modal:activate-modal"} + {:mods [:cmd] + :key :h + :action "chrome:prev-tab" + :repeat true} + {:mods [:hyper] + :key :f + :action (fn [] (alert "Haha you pressed f!"))}]}) + #+END_SRC +**** App specific customizations + Configure separate menu options and key bindings while specified apps are active. + Additionally, several lifecycle functions or action strings may be provided for each app. + + - `:activate` When an application receives keyboard focus + - `:deactivate` When an application loses keyboard focus + - `:launch` When an application is launched + - `:close` When an application is terminated + + #+BEGIN_SRC fennel + (local emacs-config + {:key "Emacs" + :activate "vim:disable" + :deactivate "vim:enable" + :launch "emacs:maximize" + :items [] + :keys []}) + + (local config {:apps [emacs-config]}) + #+END_SRC +*** Replacing spacehammer behavior + The =~/.hammerspoon/private= directory is added to the module search paths. + If you wish to change the behavior of a feature, such as vim mode, you can create =~/.hammerspoon/private/vim.fnl= to override the default implementation. + diff --git a/apps.fnl b/apps.fnl index 6d86bcd..fc1a62b 100644 --- a/apps.fnl +++ b/apps.fnl @@ -1,36 +1,31 @@ -(local windows (require :windows)) -(local multimedia (require :multimedia)) -(local slack (require :slack)) +(local utils (require :lib.utils)) -(fn add-state [modal] - (modal.add-state - :apps - {:from :* - :init (fn [self, fsm] - (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text - "e\t emacs\ng \t chrome\n f\t Firefox\n i\t iTerm\n s\t slack\n b\t brave") +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; App switcher +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - (modal.bind - self - [:cmd] :space - (fn [] (: fsm :toMain))) +(local switcher + (hs.window.switcher.new + (utils.globalFilter) + {:textSize 12 + :showTitles false + :showThumbnails false + :showSelectedTitle false + :selectedThumbnailSize 800 + :backgroundColor [0 0 0 0]})) - (slack.bind self.hotkeyModal fsm) +(fn prev-app + [] + (: switcher :previous)) - (each [key app (pairs - {:i "iTerm2", - :g "Google Chrome", - :b "Brave", - :e "Emacs", - :f "Firefox", - :m multimedia.music-app})] - (modal.bind - self nil key - (fn [] - (: fsm :toIdle) - (windows.activate-app app)))) +(fn next-app + [] + (: switcher :next)) - (: self.hotkeyModal :enter))})) -{:add-state add-state} +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +{:prev-app prev-app + :next-app next-app} diff --git a/chrome.fnl b/chrome.fnl index 269488e..18f0b15 100644 --- a/chrome.fnl +++ b/chrome.fnl @@ -1,55 +1,19 @@ +(require-macros :lib.macros) ;; setting conflicting Cmd+L (jump to address bar) keybinding to Cmd+Shift+L -(fn cmd-sl [] - (hs.hotkey.new - [:cmd :shift] :l - (fn [] - (let [app (: (hs.window.focusedWindow) :application)] - (when app - (: app :selectMenuItem ["File" "Open Location…"])))))) - -(fn browser-modal [self fsm] - (let [modal (require :modal) - emacs (require :emacs)] - (: self :bind nil "'" - (fn [] - (emacs.edit-with-emacs) - (: (modal.machine) :toIdle))) - - (: self :bind nil :escape (fn [] (: (modal.machine) :toIdle))) - - (fn self.entered [] - (modal.display-modal-text "' \tedit-with-emacs\n")))) - -(fn add-app-specific [] - (let [keybindings (require :keybindings)] - (keybindings.add-app-specific - "Google Chrome" - {:activated - (fn [] - (keybindings.activate-app-key "Google Chrome" (cmd-sl)) - - (each [h hk (pairs (keybindings.simple-tab-switching))] - (keybindings.activate-app-key "Google Chrome" hk))) - - :deactivated (fn [] (keybindings.deactivate-app-keys "Google Chrome")) - - :app-local-modal browser-modal}) - - ;; Since Chrome and Brave Browser are very similar, for now related - ;; functions are placed together - (keybindings.add-app-specific - "Brave Browser" - {:activated - (fn [] - (keybindings.activate-app-key "Brave Browser" (cmd-sl)) - - (each [h hk (pairs (keybindings.simple-tab-switching))] - (keybindings.activate-app-key "Brave Browser" hk))) - - :deactivated - (fn [] (keybindings.deactivate-app-keys "Brave Browser")) - - :app-local-modal browser-modal}))) - -{:add-app-specific add-app-specific} +(fn open-location + [] + (when-let [app (: (hs.window.focusedWindow) :application)] + (: app :selectMenuItem ["File" "Open Location…"]))) + +(fn prev-tab + [] + (hs.eventtap.keyStroke [:cmd :shift] "[")) + +(fn next-tab + [] + (hs.eventtap.keyStroke [:cmd :shift] "]")) + +{:open-location open-location + :prev-tab prev-tab + :next-tab next-tab} diff --git a/config.fnl b/config.fnl new file mode 100644 index 0000000..e958b29 --- /dev/null +++ b/config.fnl @@ -0,0 +1,450 @@ +(local windows (require :windows)) +(local {:concat concat + :logf logf} (require :lib.functional)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Default Config +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; - It is not recommended to edit this file. +;; - Changes may conflict with upstream updates. +;; - Create a ~/.hammerspoon/private/config.fnl file instead. +;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Table of Contents +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; [x] w - windows +;; [x] |-- w - Last window +;; [x] |-- cmd + hjkl - jumping +;; [x] |-- hjkl - halves +;; [x] |-- alt + hjkl - increments +;; [x] |-- shift + hjkl - resize +;; [x] |-- n, p - next, previous screen +;; [x] |-- shift + n, p - up, down screen +;; [x] |-- g - grid +;; [x] |-- m - maximize +;; [x] |-- c - center +;; [x] |-- u - undo +;; +;; [x] a - apps +;; [x] |-- e - emacs +;; [x] |-- g - chrome +;; [x] |-- f - firefox +;; [x] |-- i - iTerm +;; [x] |-- s - Slack +;; [x] |-- b - Brave +;; +;; [x] j - jump +;; +;; [x] m - media +;; [x] |-- h - previous track +;; [x] |-- l - next track +;; [x] |-- k - volume up +;; [x] |-- j - volume down +;; [x] |-- s - play\pause +;; [x] |-- a - launch player +;; +;; [x] x - emacs +;; [x] |-- c - capture +;; [x] |-- z - note +;; [x] |-- f - fullscreen +;; [x] |-- v - split +;; +;; [x] cmd-n - next-app +;; [x] cmd-p - prev-app + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Actions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn activator + [app-name] + (fn activate [] + (windows.activate-app app-name))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; General +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local music-app + "Spotify") + +(local return + {:key :space + :title "Back" + :action :previous}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Windows +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local window-jumps + [{:mods [:cmd] + :key "hjkl" + :title "Jump"} + {:mods [:cmd] + :key :h + :action "windows:jump-window-left" + :repeatable true} + {:mods [:cmd] + :key :j + :action "windows:jump-window-above" + :repeatable true} + {:mods [:cmd] + :key :k + :action "windows:jump-window-below" + :repeatable true} + {:mods [:cmd] + :key :l + :action "windows:jump-window-right" + :repeatable true}]) + +(local window-halves + [{:key "hjkl" + :title "Halves"} + {:key :h + :action "windows:resize-half-left" + :repeatable true} + {:key :j + :action "windows:resize-half-bottom" + :repeatable true} + {:key :k + :action "windows:resize-half-top" + :repeatable true} + {:key :l + :action "windows:resize-half-right" + :repeatable true}]) + +(local window-increments + [{:mods [:alt] + :key "hjkl" + :title "Increments"} + {:mods [:alt] + :key :h + :action "windows:resize-inc-left" + :repeatable true} + {:mods [:alt] + :key :j + :action "windows:resize-inc-bottom" + :repeatable true} + {:mods [:alt] + :key :k + :action "windows:resize-inc-top" + :repeatable true} + {:mods [:alt] + :key :l + :action "windows:resize-inc-right" + :repeatable true}]) + +(local window-resize + [{:mods [:shift] + :key "hjkl" + :title "Resize"} + {:mods [:shift] + :key :h + :action "windows:resize-left" + :repeatable true} + {:mods [:shift] + :key :j + :action "windows:resize-down" + :repeatable true} + {:mods [:shift] + :key :k + :action "windows:resize-up" + :repeatable true} + {:mods [:shift] + :key :l + :action "windows:resize-right" + :repeatable true}]) + +(local window-move-screens + [{:key "n, p" + :title "Move next\\previous screen"} + {:mods [:shift] + :key "n, p" + :title "Move up\\down screens"} + {:key :n + :action "windows:move-south" + :repeatable true} + {:key :p + :action "windows:move-north" + :repeatable true} + {:mods [:shift] + :key :n + :action "windows:move-west" + :repeatable true} + {:mods [:shift] + :key :p + :action "windows:move-east" + :repeatable true}]) + +(local window-bindings + (concat + [return + {:key :w + :title "Last Window" + :action "windows:jump-to-last-window"}] + window-jumps + window-halves + window-increments + window-resize + window-move-screens + [{:key :m + :title "Maximize" + :action "windows:maximize-window-frame"} + {:key :c + :title "Center" + :action "windows:center-window-frame"} + {:key :g + :title "Grid" + :action "windows:show-grid"} + {:key :u + :title "Undo" + :action "windows:undo-action"}])) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Apps Menu +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local app-bindings + [return + {:key :e + :title "Emacs" + :action (activator "Emacs")} + {:key :g + :title "Chrome" + :action (activator "Google Chrome")} + {:key :f + :title "Firefox" + :action (activator "Firefox")} + {:key :i + :title "iTerm" + :action (activator "iTerm2")} + {:key :s + :title "Slack" + :action (activator "Slack")} + {:key :b + :title "Brave" + :action (activator "Brave")} + {:key :m + :title music-app + :action (activator music-app)}]) + +(local media-bindings + [return + {:key :s + :title "Play or Pause" + :action "multimedia:play-or-pause"} + {:key :h + :title "Prev Track" + :action "multimedia:prev-track"} + {:key :l + :title "Next Track" + :action "multimedia:next-track"} + {:key :j + :title "Volume Down" + :action "multimedia:volume-down" + :repeatable true} + {:key :k + :title "Volume Up" + :action "multimedia:volume-up" + :repeatable true} + {:key :a + :title (.. "Launch " music-app) + :action (activator music-app)}]) + +(local emacs-bindings + [return + {:key :c + :title "Capture" + :action "emacs:capture"} + {:key :z + :title "Note" + :action "emacs:note"} + {:key :v + :title "Split" + :action "emacs:vertical-split-with-emacs"} + {:key :f + :title "Full Screen" + :action "emacs:full-screen"}]) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Main Menu & Config +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local menu-items + [{:key :space + :title "Alfred" + :action (activator "Alfred 4")} + {:key :w + :title "Window" + :items window-bindings} + {:key :a + :title "Apps" + :items app-bindings} + {:key :j + :title "Jump" + :action "windows:jump"} + {:key :m + :title "Media" + ;; :enter (fn [menu] + ;; (print "Entering menu: " (hs.inspect menu))) + ;; :exit (fn [menu] + ;; (print "Exiting menu: " (hs.inspect menu))) + :items media-bindings} + {:key :x + :title "Emacs" + :items emacs-bindings}]) + +(local common-keys + [{:mods [:cmd] + :key :space + :action "lib.modal:activate-modal"} + {:mods [:cmd] + :key :n + :action "apps:next-app"} + {:mods [:cmd] + :key :p + :action "apps:prev-app"} + {:mods [:cmd :ctrl] + :key :o + :action "emacs:edit-with-emacs"}]) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; App Specific Config +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local browser-keys + [{:mods [:cmd :shift] + :key :l + :action "chrome:open-location"} + {:mods [:cmd] + :key :k + :action "chrome:prev-tab" + :repeat true} + {:mods [:cmd] + :key :j + :action "chrome:next-tab" + :repeat true}]) + +(local browser-items + [{:key "'" + :title "Edit with Emacs" + :action "emacs:edit-with-emacs"}]) + +(local brave-config + {:key "Brave Browser" + :keys browser-keys + :items browser-items}) + +(local chrome-config + {:key "Google Chrome" + :keys browser-keys + :items browser-items}) + +(local emacs-config + {:key "Emacs" + :activate "vim:disable" + :deactivate "vim:enable" + :launch "emacs:maximize" + :items [] + :keys []}) + +(local grammarly-config + {:key "Grammarly" + :launch (fn []) + :items [{:mods [:ctrl] + :key :c + :title "Return to Emacs" + :action "grammarly:back-to-emacs"}] + :keys ""}) + +(local hammerspoon-config + {:key "Hammerspoon" + :items [{:key :r + :title "Reload Console" + :action hs.reload} + {:key :c + :title "Clear Console" + :action hs.console.clearConsole}] + :keys []}) + +(local slack-config + {:key "Slack" + :keys [{:mods [:cmd] + :key :g + :action "slack:scroll-to-bottom"} + {:mods [:ctrl] + :key :r + :action "slack:add-reaction"} + {:mods [:ctrl] + :key :h + :action "slack:prev-element"} + {:mods [:ctrl] + :key :l + :action "slack:next-element"} + {:mods [:ctrl] + :key :t + :action "slack:thread"} + {:mods [:ctrl] + :key :p + :action "slack:prev-day"} + {:mods [:ctrl] + :key :n + :action "slack:next-day"} + {:mods [:ctrl] + :key :e + :action "slack:scroll-up" + :repeat true} + {:mods [:ctrl] + :key :y + :action "slack:scroll-down" + :repeat true} + {:mods [:ctrl] + :key :i + :action "slack:next-history" + :repeat true} + {:mods [:ctrl] + :key :o + :action "slack:prev-history" + :repeat true} + {:mods [:ctrl] + :key :j + :action "slack:down" + :repeat true} + {:mods [:ctrl] + :key :k + :action "slack:up" + :repeat true}]}) + +(local apps + [brave-config + chrome-config + emacs-config + grammarly-config + hammerspoon-config + slack-config]) + +(local config + {:title "Main Menu" + :items menu-items + :keys common-keys + :apps apps + :hyper {:key :F18}}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +config diff --git a/core.fnl b/core.fnl index 3aa8d73..defb464 100644 --- a/core.fnl +++ b/core.fnl @@ -1,9 +1,21 @@ (hs.console.clearConsole) (hs.ipc.cliInstall) ; ensure CLI installed -;;;;;;;;;;;;;; -;; defaults ;; -;;;;;;;;;;;;;; +(local fennel (require :fennel)) +(local {:contains? contains? + :for-each for-each + :map map + :split split + :some some} (require :lib.functional)) +(require-macros :lib.macros) + +;; Make private folder override repo files +(local private (.. hs.configdir "/private")) +(tset fennel :path (.. private "/?.fnl;" fennel.path)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; defaults +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (set hs.hints.style :vimperator) (set hs.hints.showTitleThresh 4) @@ -11,62 +23,99 @@ (set hs.hints.fontSize 30) (set hs.window.animationDuration 0.2) -(global alert hs.alert.show) -(global log (fn [s] (hs.alert.show (hs.inspect s) 5))) +(global alert (fn + [str style seconds] + (hs.alert.show str + style + (hs.screen.primaryScreen) + seconds))) (global fw hs.window.focusedWindow) -;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; auto reload config ;; -;;;;;;;;;;;;;;;;;;;;;;;;;;; -(global - config-file-pathwatcher - (hs.pathwatcher.new - hs.configdir - (fn [files] - (let [u hs.fnutils - fnl-file-change? (u.some - files, - (fn [p] - (when (not (string.match p ".#")) ;; ignore emacs temp files - (let [ext (u.split p "%p")] - (or (u.contains ext "fnl") - (u.contains ext "lua"))))))] - (when fnl-file-change? (hs.reload)))))) - -(: config-file-pathwatcher :start) - - -;;;;;;;;;;;; -;; modals ;; -;;;;;;;;;;;; -(local modal (require :modal)) - -(each [_ n (pairs [:windows - :apps - :multimedia - :emacs - :chrome - :grammarly])] - (let [module (require n)] - (when module.add-state - (module.add-state modal)) - (when module.add-app-specific - (module.add-app-specific)))) - -(let [state-machine (modal.create-machine)] - (: state-machine :toMain)) - -(require :keybindings) +(fn file-exists? + [filepath] + (let [file (io.open filepath "r")] + (when file + (io.close file)) + (~= file nil))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; auto reload config +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn source-filename? + [file] + (not (string.match file ".#"))) + +(fn source-extension? + [file] + (let [ext (split "%p" file)] + (or (contains? "fnl" ext) + (contains? "lua" ext)))) + +(fn source-updated? + [file] + (and (source-filename? file) + (source-extension? file))) + +(fn config-reloader + [files] + (when (some source-updated? files) + (hs.reload))) + +(fn watch-files + [dir] + (let [watcher (hs.pathwatcher.new dir config-reloader)] + (: watcher :start) + (fn [] + (: watcher :stop)))) + +(global config-files-watcher (watch-files hs.configdir)) + +(when (file-exists? (.. private "/config.fnl")) + (global custom-files-watcher (watch-files private))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Set utility keybindings +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + ;; toggle hs.console with Ctrl+Cmd+~ (hs.hotkey.bind [:ctrl :cmd] "`" nil (fn [] - (let [console (hs.console.hswindow)] - (when console - (if (= console (hs.window.focusedWindow)) - (-> console (: :application) (: :hide)) - (-> console (: :raise) (: :focus))))))) + (when-let [console (hs.console.hswindow)] + (if (= console (hs.window.focusedWindow)) + (-> console (: :application) (: :hide)) + (-> console (: :raise) (: :focus)))))) ;; disable annoying Cmd+M for minimizing windows -(hs.hotkey.bind [:cmd] :m nil (fn [] nil)) +;; (hs.hotkey.bind [:cmd] :m nil (fn [] nil)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Load private init.fnl file (if it exists) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(when (file-exists? (.. private "/init.fnl")) + (require :private)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialize Modals & Apps +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local config (require :config)) + +(local modules [:lib.hyper + :vim + :lib.bind + :lib.modal + :lib.apps]) + +(->> modules + (map require) + (for-each + (fn [module] + (module.init config)))) diff --git a/docs/spacehammer-fsm-0.1.png b/docs/spacehammer-fsm-0.1.png new file mode 100644 index 0000000000000000000000000000000000000000..5b39e5337ab2e2120cc58cd2416dce92c013ef55 GIT binary patch literal 113712 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 YNoJ=Un7*8355PYO5!pAz!f$>4ACA4OjsO4v literal 0 HcmV?d00001 diff --git a/docs/spacehammer-fsm.graffle b/docs/spacehammer-fsm.graffle new file mode 100644 index 0000000000000000000000000000000000000000..55ea1c3b8fe17ae77a363e2715c6b526c0a47bc2 GIT binary patch literal 9595 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 literal 0 HcmV?d00001 diff --git a/emacs.fnl b/emacs.fnl index 7466c2e..265a6e6 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -1,5 +1,7 @@ +(local log (hs.logger.new "emacs.fnl" "debug")) -(fn capture [is-note] +(fn capture + [is-note] (let [key (if is-note "\"z\"" "") current-app (hs.window.focusedWindow) pid (.. "\"" (: current-app :pid) "\" ") @@ -10,12 +12,14 @@ " -e '(activate-capture-frame " pid title key " )'") timer (hs.timer.delayed.new .1 (fn [] (io.popen run-str)))] + (: log :i run-str) (: timer :start))) ;; executes emacsclient, evaluating special function that must be present in ;; Emacs config, passing pid and title of the caller app, along with display id ;; where the screen of the caller app is residing -(fn edit-with-emacs [] +(fn edit-with-emacs + [] (let [current-app (: (hs.window.focusedWindow) :application) pid (.. "\"" (: current-app :pid) "\"") title (.. "\"" (: current-app :title) "\"") @@ -26,13 +30,15 @@ " -e '(spacehammer/edit-with-emacs " pid " " title " " screen " )'")] ;; select all + copy + (: log :i run-str) (hs.eventtap.keyStroke [:cmd] :a) (hs.eventtap.keyStroke [:cmd] :c) (io.popen run-str))) ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/edit-with-emacs` in spacehammer.el -(fn edit-with-emacs-callback [pid title screen] +(fn edit-with-emacs-callback + [pid title screen] (let [emacs-app (hs.application.get :Emacs) edit-window (: emacs-app :findWindow :edit) scr (hs.screen.find (tonumber screen)) @@ -41,9 +47,6 @@ (: edit-window :moveToScreen scr) (: windows :center-window-frame)))) -;; global keybinging to invoke edit-with-emacs feature -(local edit-with-emacs-key (hs.hotkey.new [:cmd :ctrl] :o nil edit-with-emacs)) - (fn run-emacs-fn ;; executes given elisp function via emacsclient, if args table present passes ;; them to the function @@ -53,6 +56,7 @@ " -e \"(funcall '" elisp-fn (if args-lst args-lst "") ")\"")] + (: log :i run-str) (io.popen run-str))) (fn full-screen @@ -87,31 +91,6 @@ (hs.application.launchOrFocus :Emacs) (windows.rect rect-left))))))) -(fn bind [hotkeyModal fsm] - (: hotkeyModal :bind nil :c (fn [] - (: fsm :toIdle) - (capture))) - (: hotkeyModal :bind nil :z (fn [] - (: fsm :toIdle) - ;; note on currently clocked in - (capture true))) - (: hotkeyModal :bind nil :v (fn [] - (: fsm :toIdle) - (vertical-split-with-emacs))) - (: hotkeyModal :bind nil :f (fn [] - (: fsm :toIdle) - (full-screen)))) - -;; adds Emacs modal state to the FSM instance -(fn add-state [modal] - (modal.add-state - :emacs - {:from :* - :init (fn [self fsm] - (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text "c \tcapture\nz\tnote\nf\tfullscreen\nv\tsplit") - (bind self.hotkeyModal fsm) - (: self.hotkeyModal :enter))})) ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/switch-to-app` in spacehammer.el @@ -119,6 +98,7 @@ (let [app (hs.application.applicationForPID pid)] (when app (: app :activate)))) + ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/finish-edit-with-emacs` in spacehammer.el (fn switch-to-app-and-paste-from-clipboard [pid] @@ -127,36 +107,32 @@ (: app :activate) (: app :selectMenuItem [:Edit :Paste])))) -(fn disable-edit-with-emacs [] - (: edit-with-emacs-key :disable)) -(fn enable-edit-with-emacs [] - (: edit-with-emacs-key :enable)) +;; Post refactor -(fn add-app-specific [] - (let [keybindings (require :keybindings)] - (keybindings.add-app-specific - :Emacs - {:activated - (fn [] - (keybindings.disable-simple-vi-mode) - (disable-edit-with-emacs)) - :launched - (fn [] - (hs.timer.doAfter 1.5 - (fn [] - (let [app (hs.application.find :Emacs) - windows (require :windows) - modal (require :modal)] - (when app - (: app :activate) - (windows.maximize-window-frame (: modal :machine)))))))}))) +(fn maximize + [] + (hs.timer.doAfter + 1.5 + (fn [] + (let [app (hs.application.find :Emacs) + windows (require :windows) + modal (require :modal)] + (when app + (: app :activate) + (windows.maximize-window-frame)))))) + +(fn note + [] + (capture true)) -{:enable-edit-with-emacs enable-edit-with-emacs - :disable-edit-with-emacs disable-edit-with-emacs - :add-state add-state - :edit-with-emacs edit-with-emacs +{:edit-with-emacs edit-with-emacs :switchToApp switch-to-app :switchToAppAndPasteFromClipboard switch-to-app-and-paste-from-clipboard :editWithEmacsCallback edit-with-emacs-callback - :add-app-specific add-app-specific} + ;; Post refactor + :capture capture + :maximize maximize + :note note + :full-screen full-screen + :vertical-split-with-emacs vertical-split-with-emacs} diff --git a/grammarly.fnl b/grammarly.fnl index 5373d6c..4fa8b02 100644 --- a/grammarly.fnl +++ b/grammarly.fnl @@ -13,7 +13,8 @@ hs.eventtap.event.types.leftMouseUp coords) :post))) -(fn back-to-emacs [fsm] +(fn back-to-emacs + [] (let [windows (require :windows) run-str (.. "/usr/local/bin/emacsclient" " -e " @@ -28,25 +29,6 @@ (: app :selectMenuItem [:Edit :Cut]) (hs.timer.usleep 200000) (io.popen run-str) - (hs.application.launchOrFocus :Emacs) - (: fsm :toIdle))) + (hs.application.launchOrFocus :Emacs))) -(fn add-app-specific [] - (let [keybindings (require :keybindings)] - (keybindings.add-app-specific - :Grammarly - {:launched (fn [] - ;; there's a bug, when new instance of Grammarly, doesn't - ;; activate local modal key, unless rejiggered - de-focused - ;; and activated again. Here I'm simply enforcing it - (let [keybindings (require :keybindings)] - (hs.timer.doAfter 2 keybindings.initialize-local-modals))) - :app-local-modal - (fn [self fsm] - (let [modal (require :modal)] - (: self :bind [:ctrl] :c (partial back-to-emacs fsm)) - (: self :bind nil :escape (fn [] (: fsm :toIdle))) - (fn self.entered [] - (modal.display-modal-text "C-c \t- return to Emacs"))))}))) - -{:add-app-specific add-app-specific} +{:back-to-emacs back-to-emacs} diff --git a/keybindings.fnl b/keybindings.fnl deleted file mode 100644 index f64bf5f..0000000 --- a/keybindings.fnl +++ /dev/null @@ -1,180 +0,0 @@ -(local utils (require :utils)) - -(local arrows {:h :left, :j :down,:k :up,:l :right}) - -(fn simple-tab-switching [] - (let [tbl []] - (each [dir key (pairs {:j "[" :k "]"})] - (let [tf (fn [] (hs.eventtap.keyStroke [:shift :cmd] key))] - (tset tbl dir (hs.hotkey.new [:cmd] dir tf nil tf)))) - tbl)) - -(global simple-vi-mode-keymaps (or simple-vi-mode-keymaps {})) - -(fn enable-simple-vi-mode [] - (each [k v (pairs arrows)] - (when (not (. simple-vi-mode-keymaps k)) - (tset simple-vi-mode-keymaps k {}) - (table.insert (. simple-vi-mode-keymaps k) - (utils.keymap k :alt v nil)) - (table.insert (. simple-vi-mode-keymaps k) - (utils.keymap k "alt+shift" v :alt)) - (table.insert (. simple-vi-mode-keymaps k) - (utils.keymap k "alt+shift+ctrl" v :shift)))) - (each [_ ks (pairs simple-vi-mode-keymaps)] - (each [_ k (pairs ks)] - (: k :enable)))) - -(fn disable-simple-vi-mode [] - (each [_ ks (pairs simple-vi-mode-keymaps)] - (each [_ km (pairs ks)] - (: km :disable)))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; App switcher with Cmd++n/p ;; -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(let [switcher (hs.window.switcher.new - (utils.globalFilter) - {:textSize 12 - :showTitles false - :showThumbnails false - :showSelectedTitle false - :selectedThumbnailSize 800 - :backgroundColor [0 0 0 0]})] - (hs.hotkey.bind [:cmd] :n (fn [] (: switcher :next))) - (hs.hotkey.bind [:cmd] :p (fn [] (: switcher :previous)))) - -(global app-specific-keys (or app-specific-keys {})) - -;; Given an app name and hs.hotkey, binds that hotkey when app activates -(fn activate-app-key - [app hotkey] - (when (not (. app-specific-keys app)) - (tset app-specific-keys app {})) - (each [a keys (pairs app-specific-keys)] - (when (and (or (= a app) (= app :*)) - (not (. keys hotkey.idx))) - (tset keys hotkey.idx hotkey)) - (each [idx hk (pairs keys)] - (when (= idx hotkey.idx) - (: hk :enable))))) - -;; disables app-specific hotkeys for a given app name -(fn deactivate-app-keys [app] - (each [a keys (pairs app-specific-keys)] - (when (= a app) - (each [_ hk (pairs keys)] - (: hk :disable))))) - -;; every app is allowed to have a single localized modal that gets dispatched via -(global localized-app-modal-hotkey nil) - -(fn current-app-name [] - (-?> (hs.window.frontmostWindow) (: :application) (: :name))) - -(global app-specific nil) - -(fn initialize-local-modals [] - ;; if current app has app-specific config with :app-local-modal key, it allows - ;; a localized-app-modal, hotkey to invoke the modal should be enabled - (let [modal (require :modal) - fsm (: modal :machine) - cur-app (-?> (hs.window.focusedWindow) (: :application) (: :name)) - app-s (-?> app-specific (. cur-app))] - (when app-s - (if app-s.app-local-modal - ;; if current-app has `app-specific' with `:app-local-modal` key - ;; enable C-c, local-modal key - (do - (when (not localized-app-modal-hotkey) - (global - localized-app-modal-hotkey - (hs.hotkey.new [:ctrl] :c (fn [] (: fsm :toApplocal))))) - (: localized-app-modal-hotkey :enable)) - ;; if current-app doesn't have `app-specific' with `:app-local-modal` key - ;; - disable C-c, local-modal key - (when localized-app-modal-hotkey - (: localized-app-modal-hotkey :disable)))))) - -(global - app-specific - {"*" - {:activated (fn [app-name] - (enable-simple-vi-mode) - (let [emacs (require :emacs)] - (emacs.enable-edit-with-emacs)) - (initialize-local-modals)) - :launched (fn [app-name] (initialize-local-modals)) - :unhidden (fn [app-name] (initialize-local-modals))} - "iTerm2" - {:activated (fn [] - (each [h hk (pairs (simple-tab-switching))] - (activate-app-key :iTerm2 hk))) - :deactivated (fn [] (deactivate-app-keys :iTerm2))}}) - -;; (local application-watcher-constants {5 :activated -;; 6 :deactivated -;; 3 :hidden -;; 1 :launched -;; 0 :launching -;; 2 :terminated -;; 4 :unhidden}) - -(fn deactivating? [event] - (or (= event hs.application.watcher.deactivated) - (= event hs.application.watcher.terminated) - (= event hs.application.watcher.hidden))) - -(fn activating? [event] - (= event hs.application.watcher.activated)) - -(fn deactivate-local-modals [event] - (let [modal (require :modal)] - (when (deactivating? event) - (when modal.states.applocal.toIdle - (modal.states.applocal.toIdle))))) - -(fn deactivate-local-keys [app-name event] - (each [app-k m (pairs app-specific)] - (when (and (activating? event) - (not= app-k app-name)) - (let [fun (. m :deactivated)] - (when fun (fun)))))) - -(fn activate-local-keys [app-name event] - (when (activating? event) - (let [fun (-?> app-specific (. app-name) - (. :activated))] - (when fun (fun app-name))))) - -(fn activate-local-modal [app-name event] - (when (activating? event) - (let [fun (-?> app-specific (. :*) (. :activated))] - (when fun (fun app-name))))) - -(global - ;; watches applications events and if `app-specific` keys exist for the app, - ;; enables them for the app, or when the app loses focus - disables them. Also - ;; checks for applocal modals and exits modals upon app deactivation - watcher - (or - watcher - (hs.application.watcher.new - (fn [app-name event _] - (deactivate-local-modals event) - (deactivate-local-keys app-name event) - (activate-local-modal app-name event) - (activate-local-keys app-name event))))) - -(: watcher :start) - -(fn add-app-specific [app-name tbl] - (tset app-specific app-name tbl)) - -{:disable-simple-vi-mode disable-simple-vi-mode - :app-specific app-specific - :add-app-specific add-app-specific - :activate-app-key activate-app-key - :deactivate-app-keys deactivate-app-keys - :simple-tab-switching simple-tab-switching - :initialize-local-modals initialize-local-modals} diff --git a/lib/apps.fnl b/lib/apps.fnl new file mode 100644 index 0000000..c53065c --- /dev/null +++ b/lib/apps.fnl @@ -0,0 +1,263 @@ +(local atom (require :lib.atom)) +(local statemachine (require :lib.statemachine)) +(local os (require :os)) +(local {:call-when call-when + :concat concat + :find find + :filter filter + :get get + :has-some? has-some? + :join join + :last last + :map map + :merge merge + :noop noop + :slice slice + :tap 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 "apps.fnl", "debug")) + +(local actions (atom.new nil)) +(var fsm nil) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Utils +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn gen-key + [] + (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] + (atom.swap! actions (fn [] [action data]))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Event Dispatchers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn enter + [app-name] + (fsm.dispatch :enter-app app-name)) + +(fn leave + [app-name] + (fsm.dispatch :leave-app app-name)) + +(fn launch + [app-name] + (fsm.dispatch :launch-app app-name)) + +(fn close + [app-name] + (fsm.dispatch :close-app app-name)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Set Key Bindings +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn bind-app-keys + [items] + (bind-keys items)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Apps Navigation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn by-key + [target] + (fn [app] + (= app.key target))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; State Transitions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn general->enter + [state app-name] + (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] + (let [{:apps apps + :app prev-app + :unbind-keys unbind-keys} state + next-app (find (by-key app-name) apps)] + (if next-app + (do + (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}) + nil))) + +(fn in-app->leave + [state app-name] + (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] + (let [{:apps apps} state + app-menu (find (by-key app-name) apps)] + (lifecycle.launch-app app-menu) + nil)) + +(fn ->close + [state app-name] + (let [{:apps apps} state + app-menu (find (by-key app-name) apps)] + (lifecycle.close-app app-menu) + nil)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Finite State Machine States +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local states + {:general-app {:enter-app general->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}}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Watchers, Dispatchers, & Logging +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(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] + (let [event-type (. app-events event)] + (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 + [] + (let [app (hs.application.frontmostApplication)] + (if app + (: app :name) + nil))) + +(fn start-logger + [fsm] + (atom.add-watch + fsm.state :log-state + (fn log-state + [state] + (log.df "app is now: %s" (and state.app state.app.key))))) + +(fn proxy-actions + [fsm] + (atom.add-watch fsm.state :actions + (fn action-watcher + [state] + (emit state.action state.app)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; API Methods +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn get-app + [] + (when fsm + (let [state (atom.deref fsm.state)] + state.app))) + +(fn subscribe + [f] + (let [key (gen-key)] + (atom.add-watch actions key f) + (fn unsubscribe + [] + (atom.remove-watch actions key)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn init + [config] + (let [active-app (active-app-name) + initial-state {:apps config.apps + :app nil + :status :general-app + :unbind-keys nil + :action nil} + app-watcher (hs.application.watcher.new watch-apps)] + (set fsm (statemachine.new states initial-state :status)) + (start-logger fsm) + (proxy-actions fsm) + (enter active-app) + (: app-watcher :start) + (fn cleanup [] + (: app-watcher :stop)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +{:init init + :get-app get-app + :subscribe subscribe} diff --git a/lib/atom.fnl b/lib/atom.fnl new file mode 100644 index 0000000..52b985d --- /dev/null +++ b/lib/atom.fnl @@ -0,0 +1,47 @@ +(fn atom + [initial] + {:state initial + :watchers {}}) + +(fn copy + [tbl] + (if (~= (type tbl) :table) + tbl + (let [copy-tbl (setmetatable {} (getmetatable tbl))] + (each [k v (pairs tbl)] + (tset copy-tbl (copy k) (copy v))) + copy-tbl))) + +(fn deref + [atom] + (. atom :state)) + +(fn notify-watchers + [atom next-value prev-value] + (let [watchers (. atom :watchers)] + (each [_ f (pairs watchers)] + (f next-value prev-value)))) + +(fn add-watch + [atom key f] + (tset atom :watchers key f)) + +(fn remove-watch + [atom key] + (table.remove (. atom :watchers) key)) + +(fn swap! + [atom f ...] + (let [prev-value (deref atom) + next-value (f (copy prev-value) (table.unpack [...]))] + (set atom.state next-value) + (notify-watchers atom next-value prev-value) + atom)) + +{:atom atom + :new atom + :deref deref + :notify-watchers notify-watchers + :add-watch add-watch + :remove-watch remove-watch + :swap! swap!} diff --git a/lib/bind.fnl b/lib/bind.fnl new file mode 100644 index 0000000..19c823d --- /dev/null +++ b/lib/bind.fnl @@ -0,0 +1,85 @@ +(local hyper (require :lib.hyper)) +(local {:contains? contains? + :split split} + (require :lib.functional)) + +(local log (hs.logger.new "bind.fnl" "debug")) + +(fn do-action + [action] + (let [[file fn-name] (split ":" action) + module (require file)] + (if (. module fn-name) + (: module fn-name) + (do + (log.wf "Could not invoke action %s" + action))))) + + +(fn create-action-fn + [action] + (fn [] + (do-action action))) + + +(fn action->fn + [action] + (match (type action) + :function action + :string (create-action-fn action) + _ (do + (log.wf "Could not create action handler for %s" + (hs.inspect action)) + (fn [] true)))) + + +(fn bind-keys + [items] + (let [modal (hs.hotkey.modal.new [] nil)] + (each [_ item (ipairs items)] + (let [{:key key + :mods mods + :action action + :repeat repeat} item + mods (or mods []) + action-fn (action->fn action)] + (if repeat + (: modal :bind mods key action-fn nil action-fn) + (: modal :bind mods key nil action-fn)))) + (: modal :enter) + (fn destroy-bindings + [] + (when modal + (: modal :exit) + (: modal :delete))))) + +(fn bind-global-keys + [items] + (each [_ item (ipairs items)] + (let [{:key key} item + mods (or item.mods []) + action-fn (action->fn item.action)] + (if (contains? :hyper mods) + (hyper.bind key action-fn) + (let [binding (hs.hotkey.bind mods key action-fn)] + (fn unbind + [] + (: binding :delete))))))) + +(fn unbind-global-keys + [bindings] + (each [_ unbind (ipairs bindings)] + (unbind))) + +(fn init + [config] + (let [keys (or config.keys []) + bindings (bind-global-keys keys)] + (fn cleanup + [] + (unbind-global-keys bindings)))) + +{:init init + :action->fn action->fn + :bind-keys bind-keys + :do-action do-action} diff --git a/lib/functional.fnl b/lib/functional.fnl new file mode 100644 index 0000000..e4d29f1 --- /dev/null +++ b/lib/functional.fnl @@ -0,0 +1,209 @@ +(local fu hs.fnutils) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Simple Utils +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn call-when + [f] + (when (and f (= (type f) :function)) + (f))) + +(fn contains? + [x xs] + (and xs (fu.contains xs x))) + +(fn find + [f tbl] + (fu.find tbl f)) + +(fn get + [prop-name tbl] + (if tbl + (. prop-name tbl) + (fn [tbl] + (. tbl prop-name)))) + +(fn has-some? + [list] + (and list (> (# list) 0))) + +(fn identity + [x] x) + +(fn join + [sep list] + (table.concat list sep)) + +(fn last + [list] + (. list (# list))) + +(fn logf + [...] + (let [prefixes [...]] + (fn [x] + (print (table.unpack prefixes) (hs.inspect x))))) + +(fn noop + [] + nil) + +(fn slice-start-end + [start end list] + (let [end (if (< end 0) + (+ (# list) end) + end)] + (var sliced []) + (for [i start end] + (table.insert sliced (. list i))) + sliced)) + +(fn slice-start + [start list] + (slice-start-end start (# list) list)) + +(fn slice + [start end list] + (if (and (= (type end) :table) + (not list)) + (slice-start start end) + (slice-start-end start end list))) + +(fn split + [search str] + (fu.split str search)) + +(fn tap + [f x ...] + (f x (table.unpack [...])) + x) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Reduce Primitives +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn seq? + [tbl] + (~= (. tbl 1) nil)) + +(fn seq + [tbl] + (if (seq? tbl) + (ipairs tbl) + (pairs tbl))) + +(fn reduce + [f acc tbl] + (var result acc) + (each [k v (seq tbl)] + (set result (f result v k))) + result) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Reducers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn for-each + [f tbl] + (fu.each tbl f)) + +(fn get-in + [paths tbl] + (reduce + (fn [tbl path] + (-?> tbl (. path))) + tbl + paths)) + +(fn map + [f tbl] + (reduce + (fn [new-tbl v k] + (table.insert new-tbl (f v k)) + new-tbl) + [] + tbl)) + +(fn merge + [...] + (let [tbls [...]] + (reduce + (fn merger [merged tbl] + (each [k v (pairs tbl)] + (tset merged k v)) + merged) + {} + tbls))) + +(fn filter + [f tbl] + (reduce + (fn [xs v k] + (when (f v k) + (table.insert xs v)) + xs) + [] + tbl)) + +(fn concat + [...] + (reduce + (fn [cat tbl] + (each [_ v (ipairs tbl)] + (table.insert cat v)) + cat) + [] + [...])) + +(fn some + [f tbl] + (let [filtered (filter f tbl)] + (>= (# filtered) 1))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Others +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn eq? + [l1 l2] + (if (and (= (type l1) (type l2) "table") + (= (# l1) (# l2))) + (fu.every l1 + (fn [v] (contains? v l2))) + (= (type l1) (type l2)) + (= l1 l2) + false)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +{:call-when call-when + :concat concat + :contains? contains? + :eq? eq? + :filter filter + :find find + :for-each for-each + :get get + :get-in get-in + :has-some? has-some? + :identity identity + :join join + :last last + :logf logf + :map map + :merge merge + :noop noop + :reduce reduce + :seq seq + :seq? seq? + :some some + :slice slice + :split split + :tap tap} diff --git a/lib/hyper.fnl b/lib/hyper.fnl new file mode 100644 index 0000000..50f0076 --- /dev/null +++ b/lib/hyper.fnl @@ -0,0 +1,72 @@ +(require-macros :lib.macros) +(local {:find find} (require :lib.functional)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Hyper Mode +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; - Bind a key or a combination of keys to trigger a hyper mode. +;; - Often this is cmd+shift+alt+ctrl +;; - Or a virtual F17 key if using something like Karabiner Elements +;; - The goal is to have a mode no other apps will be listening for +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(var hyper (hs.hotkey.modal.new)) +(var enabled false) + +(fn enter-hyper-mode + [] + (set enabled true) + (: hyper :enter)) + +(fn exit-hyper-mode + [] + (set enabled false) + (: hyper :exit)) + +(fn unbind-key + [key] + (when-let [binding (find (fn [{:msg msg}] + (= msg key)) + hyper.keys)] + (: binding :delete))) + +(fn bind + [key f] + (: hyper :bind nil key nil f) + (fn unbind + [] + (unbind-key key))) + +(fn bind-spec + [{:key key + :mods mods + :press press-f + :release release-f + :repeat repeat-f}] + (: hyper :bind nil key press-f release-f repeat-f) + (fn unbind + [] + (unbind-key key))) + +(fn init + [config] + (let [h (or config.hyper {})] + (hs.hotkey.bind (or h.mods []) + h.key + enter-hyper-mode + exit-hyper-mode))) + +(fn enabled? + [] + (= enabled true)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +{:init init + :bind bind + :bind-spec bind-spec + :enabled? enabled?} diff --git a/lib/lifecycle.fnl b/lib/lifecycle.fnl new file mode 100644 index 0000000..998a6c5 --- /dev/null +++ b/lib/lifecycle.fnl @@ -0,0 +1,50 @@ +(local {:do-action do-action} (require :lib.bind)) +(local log (hs.logger.new "lifecycle.fnl" "debug")) + +(fn do-method + [obj method-name] + (let [method (. obj method-name)] + (match (type method) + :function (method obj) + :string (do-action method) + _ (do + (log.wf "Could not call lifecycle method %s on %s" + method-name + obj))))) + +(fn activate-app + [menu] + (when (and menu menu.activate) + (do-method menu :activate))) + +(fn close-app + [menu] + (when (and menu menu.close) + (do-method menu :close))) + +(fn deactivate-app + [menu] + (when (and menu menu.deactivate) + (do-method menu :deactivate))) + +(fn enter-menu + [menu] + (when (and menu menu.enter) + (do-method menu :enter))) + +(fn exit-menu + [menu] + (when (and menu menu.exit) + (do-method menu :exit))) + +(fn launch-app + [menu] + (when (and menu menu.launch) + (do-method menu :launch))) + +{:activate-app activate-app + :close-app close-app + :deactivate-app deactivate-app + :enter-menu enter-menu + :exit-menu exit-menu + :launch-app launch-app} diff --git a/lib/macros.fnl b/lib/macros.fnl new file mode 100644 index 0000000..4125fa7 --- /dev/null +++ b/lib/macros.fnl @@ -0,0 +1,8 @@ +(fn when-let + [[var-name value] body1 ...] + (assert body1 "expected body") + `(let [@var-name @value] + (when @var-name + @body1 @...))) + +{:when-let when-let} diff --git a/lib/modal.fnl b/lib/modal.fnl new file mode 100644 index 0000000..ba7a838 --- /dev/null +++ b/lib/modal.fnl @@ -0,0 +1,355 @@ +(local atom (require :lib.atom)) +(local statemachine (require :lib.statemachine)) +(local apps (require :lib.apps)) +(local {:call-when call-when + :concat concat + :find find + :filter filter + :get get + :has-some? has-some? + :join join + :last last + :map map + :merge merge + :noop noop + :slice slice + :tap tap} + (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) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; General Utils +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn timeout + [f] + (let [task (hs.timer.doAfter 2 f)] + (fn destroy-task + [] + (when task + (: task :stop) + nil)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Event Dispatchers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn activate-modal + [menu-key] + (fsm.dispatch :activate menu-key)) + + +(fn deactivate-modal + [] + (fsm.dispatch :deactivate)) + + +(fn previous-modal + [] + (fsm.dispatch :previous)) + + +(fn start-modal-timeout + [] + (fsm.dispatch :start-timeout)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Set Key Bindings +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn create-action-trigger + [{:action action :repeatable repeatable :timeout timeout}] + (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}] + (fn [] + (activate-modal key))) + + +(fn select-trigger + [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] + {:mods (or item.mods []) + :key item.key + :action (select-trigger item)}) + + +(fn bind-menu-keys + [items] + (-> items + (->> (filter (fn [item] + (or item.action + item.items))) + (map bind-item)) + (concat [{:key :ESCAPE + :action deactivate-modal}]) + (bind-keys))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Display Modals +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local mod-chars {:cmd "CMD" + :alt "OPT" + :shift "SHFT" + :tab "TAB"}) + +(fn format-key + [item] + (let [mods (-?>> item.mods + (map (fn [m] (or (. mod-chars m) m))) + (join " "))] + (.. (or mods "") + (if mods " + " "") + item.key))) + + +(fn modal-alert + [menu] + (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 + [{:menu menu + :prev-menu prev-menu + :unbind-keys unbind-keys + :stop-timeout stop-timeout + :history history}] + (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 + (concat [] history [menu]) + [])}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Menus, & Config Navigation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn by-key + [target] + (fn [item] + (and (= (. item :key) target) + (has-some? item.items)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; State Transitions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +(fn idle->active + [state data] + (let [{:config config + :stop-timeout stop-timeout + :unbind-keys unbind-keys} state + 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})))) + + +(fn active->idle + [state data] + (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})) + + +(fn active->enter-app + [state app-menu] + (let [{:config config + :menu prev-menu + :stop-timeout stop-timeout + :unbind-keys unbind-keys + :history history} state + menu (if (and app-menu (has-some? app-menu.items)) + app-menu + config)] + (if (= menu.key prev-menu.key) + nil + (merge {:history [menu]} + (show-modal-menu + {:stop-timeout stop-timeout + :unbind-keys unbind-keys + :menu menu + :history history}))))) + + +(fn active->leave-app + [state] + (let [{:config config + :menu prev-menu} state] + (if (= prev-menu.key config.key) + nil + (idle->active state)))) + + +(fn active->submenu + [state menu-key] + (let [{:config config + :menu prev-menu + :stop-timeout stop-timeout + :unbind-keys unbind-keys + :history history} state + 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}))))) + + +(fn active->timeout + [state] + (call-when state.stop-timeout) + {:stop-timeout (timeout deactivate-modal)}) + + +(fn submenu->previous + [state] + (let [{:config config + :history history} state + history (slice 1 -1 history) + main-menu (= 0 (# history)) + navigate (if main-menu + idle->active + active->submenu)] + (navigate (merge state + {:history history})))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Finite State Machine States +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +(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] + (atom.add-watch + fsm.state :log-state + (fn log-state + [state] + (log.df "state is now: %s" state.status)))) + +(fn proxy-app-action + [[action data]] + (fsm.dispatch action data)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn init + [config] + (let [initial-state {:config config + :history [] + :menu nil + :status :idle + :stop-timeout nil + :unbind-keys nil} + unsubscribe (apps.subscribe proxy-app-action)] + (set fsm (statemachine.new states initial-state :status)) + (start-logger fsm) + (fn cleanup [] + (unsubscribe)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +{:init init + :activate-modal activate-modal} diff --git a/lib/statemachine.fnl b/lib/statemachine.fnl new file mode 100644 index 0000000..e245a90 --- /dev/null +++ b/lib/statemachine.fnl @@ -0,0 +1,123 @@ +(local atom (require :lib.atom)) +(local {:filter filter + :logf logf + :map map + :merge merge + :tap tap} (require :lib.functional)) + +(local log (hs.logger.new "\tstatemachine.fnl\t" "debug")) + +" +Transition +Takes an action fn, state, and extra action data +Returns updated state +" +(fn transition + [action-fn state data] + (action-fn state data)) + + +" +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. +" +(fn update-state + [state-atom update] + (when update + (atom.swap! + state-atom + (fn [state] + (-> {} + (merge state update) + (remove-nils update)))))) + +" +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)) + +" +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))))) + + +" +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})) + +{:new create-machine} diff --git a/lib/text.fnl b/lib/text.fnl new file mode 100644 index 0000000..07d4191 --- /dev/null +++ b/lib/text.fnl @@ -0,0 +1,30 @@ +(local {:map map + :merge merge + :reduce reduce} (require :lib.functional)) +;; Menu Column Alignment +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +(fn max-length + [items] + (reduce + (fn [max [key _]] (math.max max (# key))) + 0 + items)) + + +(fn pad-str + [char max str] + (let [diff (- max (# str))] + (.. str (string.rep char diff)))) + + +(fn align-columns + [items] + (let [max (max-length items)] + (map + (fn [[key action]] + (.. (pad-str "." (+ max 1) (.. key " ")) "..... " action)) + items))) + +{:align-columns align-columns} diff --git a/utils.lua b/lib/utils.lua similarity index 100% rename from utils.lua rename to lib/utils.lua diff --git a/modal.fnl b/modal.fnl deleted file mode 100644 index 61d24ae..0000000 --- a/modal.fnl +++ /dev/null @@ -1,121 +0,0 @@ -(local modal {}) -(local utils (require :utils)) -(local statemachine (require :statemachine)) -(local windows (require :windows)) -(local keybindings (require :keybindings)) - -(global states {}) - -(fn exit-all-modals [fsm] - (each [k s (pairs states)] - (when s.hotkeyModal - (: s.hotkeyModal :exit))) - (each [_ m (pairs states.applocal.modals)] - (when m (: m :exit)))) - -(fn display-modal-text [txt] - (hs.alert.closeAll) - (alert txt 999999)) - -(fn bind [modal mods key fun] - (: modal.hotkeyModal :bind mods key fun)) - -(fn filter-allowed-apps [w] - (if (: w :isStandard) - true - false)) - -(global states - {:idle {:from :* - :to :idle - :callback (fn [self event from to] - (hs.alert.closeAll) - (exit-all-modals self))} - :main {:from :* - :to :main - :init (fn [self fsm] - (if self.hotkeyModal - (: self.hotkeyModal :enter) - (set self.hotkeyModal (hs.hotkey.modal.new [:cmd] :space))) - - (bind - self nil :space - (fn [] - (: fsm :toIdle) - (windows.activate-app "Alfred 4"))) - - (bind self nil :escape (fn [] (: fsm :toIdle))) - (bind self nil :q (fn [] (: fsm :toIdle))) - (bind self :ctrl :g (fn [] (: fsm :toIdle))) - - (bind self nil :w (fn [] (: fsm :toWindows))) - (bind self nil :a (fn [] (: fsm :toApps))) - (bind self nil :m (fn [] (: fsm :toMedia))) - (bind self nil :x (fn [] (: fsm :toEmacs))) - - ;; jump to any app with :j - (bind self nil :j (fn [] - (let [wns (hs.fnutils.filter (hs.window.allWindows) filter-allowed-apps)] - (hs.hints.windowHints wns nil true) - (: fsm :toIdle)))) - - (fn self.hotkeyModal.entered [] - (display-modal-text "w \t- windows\na \t- apps\n j \t- jump\nm - media\nx\t- emacs")))} - - ;; `:applocal` is a state that gets activated whenever user would switch to an - ;; app that allows localized modals. Localized modals are enabled by adding - ;; `:app-local-modal' key in `keybindings.app-specific' - :applocal {:from :* - :modals [] - :init (fn [self fsm] - (set self.toIdle (fn [] (: fsm :toIdle))) - ;; - read `keybindings.app-specific` - ;; - find a key matching with current app-name - ;; - if has `:app-local-modal' key, activate the modal - (let [cur-app (-?> (hs.window.focusedWindow) (: :application) (: :name)) - fnd (-?> keybindings.app-specific (. cur-app) (. :app-local-modal)) - mdl (fn [] (. self.modals cur-app))] - (when fnd - (when (not (mdl)) - (tset self.modals cur-app (hs.hotkey.modal.new))) - (fnd (mdl) fsm) - (: (mdl) :enter))))}}) - -;; stores instance of finite-state-machine. -;; Externally accessible via `modal.machine()' -(global machine nil) - -;; creates instance of finite-state-machine based on `modal.states`. Other -;; modules can add more states using `modal.add-state`, but then `create-machine' -;; has to run after it again -(fn create-machine [] - (let [events {} - callbacks {}] - (each [k s (pairs states)] - (table.insert events {:name (.. :to (utils.capitalize k)) - :from (or s.from {:main :idle}) - :to (or s.to k)})) - (each [k s (pairs states)] - (tset - callbacks (.. "on" k) - (or s.callback - (fn [self event from to] - (let [st (. states to)] - (st.init st self)))))) - - (let [fsm (statemachine.create - {:initial :idle - :events events - :callbacks callbacks})] - (global machine fsm) - machine))) - -(fn add-state [name state] - (tset states name state)) - -{:create-machine create-machine - :add-state add-state - :display-modal-text display-modal-text - :bind bind - :states states - :machine (fn [] machine)} diff --git a/multimedia.fnl b/multimedia.fnl index 2826492..f989890 100644 --- a/multimedia.fnl +++ b/multimedia.fnl @@ -1,42 +1,30 @@ -(local music-app "Google Play Music Desktop Player") - (fn m-key [key] (: (hs.eventtap.event.newSystemKeyEvent (string.upper key) true) :post) (hs.timer.usleep 5) (: (hs.eventtap.event.newSystemKeyEvent (string.upper key) false) :post)) -(fn bind [hotkeyMmodal fsm] - (: hotkeyMmodal :bind nil :a - (fn [] - (hs.application.launchOrFocus music-app) - (: fsm :toIdle))) +(fn play-or-pause + [] + (m-key :play)) - (: hotkeyMmodal :bind nil :h (fn [] (m-key :previous) (: fsm :toIdle))) - (: hotkeyMmodal :bind nil :l (fn [] (m-key :next) (: fsm :toIdle))) - (let [sup (fn [] (m-key :sound_up))] - (: hotkeyMmodal :bind nil :k sup nil sup)) - (let [sdn (fn [] (m-key :sound_down))] - (: hotkeyMmodal :bind nil :j sdn nil sdn)) - (let [pl (fn [] - (m-key :play) - (: fsm :toIdle))] - (: hotkeyMmodal :bind nil :s pl))) +(fn prev-track + [] + (m-key :previous)) -(fn add-state [modal] - (modal.add-state - :media - {:from :* - :init (fn [self, fsm] - (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text "h \t previous track\nl \t next track\nk \t volume up\nj \t volume down\ns \t play/pause\na \t launch player") +(fn next-track + [] + (m-key :next)) - (modal.bind - self - [:cmd] :space - (fn [] (: fsm :toMain))) +(fn volume-up + [] + (m-key :sound_up)) - (bind self.hotkeyModal fsm) - (: self.hotkeyModal :enter))})) +(fn volume-down + [] + (m-key :sound_down)) -{:add-state add-state - :music-app music-app} +{:play-or-pause play-or-pause + :prev-track prev-track + :next-track next-track + :volume-up volume-up + :volume-down volume-down} diff --git a/slack.fnl b/slack.fnl index c1afe7b..3cac595 100644 --- a/slack.fnl +++ b/slack.fnl @@ -1,78 +1,100 @@ -(local keybindings (require :keybindings)) (local windows (require :windows)) -(local - slack-local-hotkeys - [;; jump to end of thread on Cmd-g - (hs.hotkey.bind - [:cmd] :g - (fn [] - (windows.set-mouse-cursor-at :Slack) - ;; this number should be big enough to take you - ;; to the bottom of the chat window - (hs.eventtap.scrollWheel [0 -20000] {}))) - ;; add a reaction - (hs.hotkey.bind [:ctrl] :r (fn [] (hs.eventtap.keyStroke [:cmd :shift] "\\"))) +;; Utils - ;; F6 mode - (hs.hotkey.bind [:ctrl] :h (fn [] (hs.eventtap.keyStroke [:shift] :f6))) - (hs.hotkey.bind [:ctrl] :l (fn [] (hs.eventtap.keyStroke [] :f6))) +(fn scroll-to-bottom + [] + (windows.set-mouse-cursor-at :Slack) + (hs.eventtap.scrollWheel [0 -20000] {})) +(fn add-reaction + [] + (hs.eventtap.keyStroke [:cmd :shift] "\\")) + +(fn prev-element + [] + (hs.eventtap.keyStroke [:shift] :f6)) + +(fn next-element + [] + (hs.eventtap.keyStroke nil :f6)) + +(fn thread + [] ;; Start a thread on the last message. It doesn't always work, because of ;; stupid Slack App inconsistency with TabIndexes - (hs.hotkey.bind - [:ctrl] :t - (fn [] - (hs.eventtap.keyStroke [:shift] :f6) - (hs.eventtap.keyStroke [] :right) - (hs.eventtap.keyStroke [] :space))) - - ;; scroll to prev/next day - (hs.hotkey.bind [:ctrl] :p (fn [] (hs.eventtap.keyStroke [:shift] :pageup))) - (hs.hotkey.bind [:ctrl] :n (fn [] (hs.eventtap.keyStroke [:shift] :pagedown)))]) - - - -;; Slack client doesn't allow convenient method to scrolling in thread with keyboard -;; adding C-e, C-y bindings for scrolling up and down -(each [k dir (pairs {:e -3 :y 3})] - (let [scroll-fn (fn [] - (windows.set-mouse-cursor-at :Slack) - (hs.eventtap.scrollWheel [0 dir] {}))] - (table.insert slack-local-hotkeys (hs.hotkey.new [:ctrl] k scroll-fn nil scroll-fn)))) - - -;; Ctrl-o|Ctrl-i to go back and forth in history -(each [k dir (pairs {:o "[" :i "]"})] - (let [back-fwd (fn [] (hs.eventtap.keyStroke [:cmd] dir))] - (table.insert slack-local-hotkeys (hs.hotkey.new [:ctrl] k back-fwd nil back-fwd)))) - - -;; C-n|C-p - for up and down (instead of using arrow keys) -(each [k dir (pairs {:p :up :n :down})] - (let [up-n-down (fn [] (hs.eventtap.keyStroke nil dir))] - (table.insert slack-local-hotkeys (hs.hotkey.new [:ctrl] k up-n-down nil up-n-down)))) - -(tset - keybindings.app-specific :Slack - {:activated (fn [] - (hs.fnutils.each slack-local-hotkeys - (partial keybindings.activate-app-key :Slack))) - :deactivated (fn [] (keybindings.deactivate-app-keys :Slack))}) - -(fn bind [modal fsm] - - ;; open "Jump to dialog immediately after jumping to Slack GUI through `Apps` modal" - (: modal :bind nil :s - (fn [] - (hs.application.launchOrFocus "/Applications/Slack.app") - (let [app (hs.application.find :Slack)] - (when app - (: app :activate) - (hs.timer.doAfter .2 windows.highlight-active-window) - (hs.eventtap.keyStroke [:cmd] :t) - (: app :unhide)) - (: fsm :toIdle))))) - -{:bind bind} + (hs.eventtap.keyStroke [:shift] :f6) + (hs.eventtap.keyStroke [] :right) + (hs.eventtap.keyStroke [] :space)) + +(fn quick-switcher + [] + (windows.activate-app "/Applications/Slack.app") + (let [app (hs.application.find :Slack)] + (when app + (hs.eventtap.keyStroke [:cmd] :t) + (: app :unhide)))) + + +;; scroll to prev/next day + +(fn prev-day + [] + (hs.eventtap.keyStroke [:shift] :pageup)) + +(fn next-day + [] + (hs.eventtap.keyStroke [:shift] :pagedown)) + +(fn scroll-slack + [dir] + (windows.set-mouse-cursor-at :Slack) + (hs.eventtap.scrollWheel [0 dir] {})) + +(fn scroll-up + [] + (scroll-slack -3)) + +(fn scroll-down + [] + (scroll-slack 3)) + + +;; History + +(fn prev-history + [] + (hs.eventtap.keyStroke [:cmd] "[")) + +(fn next-history + [] + (hs.eventtap.keyStroke [:cmd] "]")) + + +;; Arrow keys + +(fn up + [] + (hs.eventtap.keyStroke nil :up)) + +(fn down + [] + (hs.eventtap.keyStroke nil :down)) + + + +{:add-reaction add-reaction + :down down + :next-day next-day + :next-element next-element + :next-history next-history + :prev-day prev-day + :prev-element prev-element + :prev-history prev-history + :quick-switcher quick-switcher + :scroll-down scroll-down + :scroll-to-bottom scroll-to-bottom + :scroll-up scroll-up + :thread thread + :up up} diff --git a/statemachine.lua b/statemachine.lua deleted file mode 100644 index d5b505b..0000000 --- a/statemachine.lua +++ /dev/null @@ -1,156 +0,0 @@ -local machine = {} -machine.__index = machine - -local NONE = "none" -local ASYNC = "async" - -local function call_handler(handler, params) - if handler then - return handler(table.unpack(params)) - end -end - -local function create_transition(name) - local can, to, from, params - - local function transition(self, ...) - if self.asyncState == NONE then - can, to = self:can(name) - from = self.current - params = { self, name, from, to, ...} - - if not can then return false end - self.currentTransitioningEvent = name - - local beforeReturn = call_handler(self["onbefore" .. name], params) - local leaveReturn = call_handler(self["onleave" .. from], params) - - if beforeReturn == false or leaveReturn == false then - return false - end - - self.asyncState = name .. "WaitingOnLeave" - - if leaveReturn ~= ASYNC then - transition(self, ...) - end - - return true - elseif self.asyncState == name .. "WaitingOnLeave" then - self.current = to - - local enterReturn = call_handler(self["onenter" .. to] or self["on" .. to], params) - - self.asyncState = name .. "WaitingOnEnter" - - if enterReturn ~= ASYNC then - transition(self, ...) - end - - return true - elseif self.asyncState == name .. "WaitingOnEnter" then - call_handler(self["onafter" .. name] or self["on" .. name], params) - call_handler(self["onstatechange"], params) - self.asyncState = NONE - self.currentTransitioningEvent = nil - return true - else - if string.find(self.asyncState, "WaitingOnLeave") or string.find(self.asyncState, "WaitingOnEnter") then - self.asyncState = NONE - transition(self, ...) - return true - end - end - - self.currentTransitioningEvent = nil - return false - end - - return transition -end - -local function add_to_map(map, event) - if type(event.from) == 'string' then - map[event.from] = event.to - else - for _, from in ipairs(event.from) do - map[from] = event.to - end - end -end - -function machine.create(options) - assert(options.events) - - local fsm = {} - setmetatable(fsm, machine) - - fsm.options = options - fsm.current = options.initial or 'none' - fsm.asyncState = NONE - fsm.events = {} - - for _, event in ipairs(options.events or {}) do - local name = event.name - fsm[name] = fsm[name] or create_transition(name) - fsm.events[name] = fsm.events[name] or { map = {} } - add_to_map(fsm.events[name].map, event) - end - - for name, callback in pairs(options.callbacks or {}) do - fsm[name] = callback - end - - return fsm -end - -function machine:is(state) - return self.current == state -end - -function machine:can(e) - local event = self.events[e] - local to = event and event.map[self.current] or event.map['*'] - return to ~= nil, to -end - -function machine:cannot(e) - return not self:can(e) -end - -function machine:todot(filename) - local dotfile = io.open(filename,'w') - dotfile:write('digraph {\n') - local transition = function(event,from,to) - dotfile:write(string.format('%s -> %s [label=%s];\n',from,to,event)) - end - for _, event in pairs(self.options.events) do - if type(event.from) == 'table' then - for _, from in ipairs(event.from) do - transition(event.name,from,event.to) - end - else - transition(event.name,event.from,event.to) - end - end - dotfile:write('}\n') - dotfile:close() -end - -function machine:transition(event) - if self.currentTransitioningEvent == event then - return self[self.currentTransitioningEvent](self) - end -end - -function machine:cancelTransition(event) - if self.currentTransitioningEvent == event then - self.asyncState = NONE - self.currentTransitioningEvent = nil - end -end - -machine.NONE = NONE -machine.ASYNC = ASYNC - -return machine diff --git a/vim.fnl b/vim.fnl new file mode 100644 index 0000000..ed54b7c --- /dev/null +++ b/vim.fnl @@ -0,0 +1,408 @@ +(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 {:bind-keys bind-keys} (require :lib.bind)) +(local log (hs.logger.new "vim.fnl" "debug")) + +;; Debug +(local hyper (require :lib.hyper)) + +(var fsm nil) + +(local shape {:x 900 + :y 900 + :h 40 + :w 180}) +(local text (hs.drawing.text shape "")) +(local box (hs.drawing.rectangle shape)) + +(: text :setBehaviorByLabels [:canJoinAllSpaces + :transient]) + +(: box :setBehaviorByLabels [:canJoinAllSpaces + :transient]) + +(: text :setLevel :overlay) +(: box :setLevel :overlay) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Actions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn disable + [] + (when fsm + (: box :hide) + (: text :hide) + (fsm.dispatch :disable))) + +(fn enable + [] + (when fsm + (fsm.dispatch :enable))) + +(fn normal + [] + (when fsm + (fsm.dispatch :normal))) + +(fn visual + [] + (when fsm + (fsm.dispatch :visual))) + +(fn insert + [] + (when fsm + (fsm.dispatch :insert))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers, Utils & Config +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(var ignore-fx false) + +(fn keystroke + [target-mods target-key] + (set ignore-fx true) + (hs.eventtap.keyStroke (or target-mods []) target-key 10000) + (hs.timer.doAfter 0.1 (fn [] (set ignore-fx false)))) + +(fn key-fn + [target-mods target-key] + (fn [] (keystroke target-mods target-key))) + +(local bindings + {:normal [{:key :ESCAPE + :action disable} + {:key :h + :action (key-fn [] :left) + :repeat true} + {:key :j + :action (key-fn [] :down) + :repeat :true} + {:key :k + :action (key-fn [] :up) + :repeat true} + {:key :l + :action (key-fn [] :right) + :repeat true} + {:mods [:shift] + :key :i + :action (fn [] + (insert) + (keystroke [:ctrl] :a))} + {:key :i + :action insert} + {:key :a + :action (fn [] + (insert) + (keystroke nil :right))} + {:mods [:shift] + :key :a + :action (fn [] + (insert) + (keystroke [:ctrl] :e))} + {:key :v + :action visual} + {:mods [:shift] + :key :v + :action (fn [] + (keystroke [:cmd] :left) + (keystroke [:shift :cmd] :right) + (visual))} + {:key :/ + :action (key-fn [:cmd] :f)} + {:key :x + :action (key-fn nil :forwarddelete)} + {:key :o + :action (fn [] + (keystroke [:cmd] :right) + (keystroke [:alt] :return) + (insert))} + {:mods [:shift] + :key :o + :action (fn [] + (keystroke [:cmd] :left) + (keystroke [:alt] :return) + (keystroke nil :left) + (insert))} + {:key :p + :action (key-fn [:cmd] :v)} + {:key :0 + :action (key-fn [:cmd] :left)} + {:mods [:shift] + :key :4 + :action (key-fn [:cmd] :right)} + {:mods [:ctrl] + :key :u + :action (key-fn nil :pageup)} + {:mods [:ctrl] + :key :d + :action (key-fn nil :pagedown)} + {:mods [:shift] + :key :g + :action (key-fn [:cmd] :down)} + {:key :b + :action (key-fn [:alt] :left)} + {:key :w + :action (fn [] + (keystroke [:alt] :right) + (keystroke nil :right))} + {:key :u + :action (key-fn [:cmd] :z)} + {:mods [:ctrl] + :key :r + :action (key-fn [:cmd :shift] :z)} + {:key :c + :action (fn [] + (keystroke [] :forwarddelete) + (insert))} + {:mods [:shift] + :key :d + :action (fn [] + (keystroke [:cmd] :left) + (keystroke [:shift :cmd] :right) + (keystroke nil :delete) + (keystroke nil :delete))} + {:mods [:shift] + :key :c + :action (fn [] + (keystroke [:cmd] :left) + (keystroke [:shift :cmd] :right) + (keystroke nil :delete) + (insert))} + {:key :s + :action (fn [] + (keystroke nil :forwarddelete) + (insert))} + {:mods [:ctrl] + :key :h + :action "windows:jump-window-left"} + {:mods [:ctrl] + :key :j + :action "windows:jump-window-below"} + {:mods [:ctrl] + :key :k + :action "windows:jump-window-above"} + {:mods [:ctrl] + :key :l + :action "windows:jump-window-right"}] + :insert [{:key :ESCAPE + :action normal}] + :visual [{:key :ESCAPE + :action (fn [] + (keystroke nil :left) + (normal))} + {:key :h + :action (key-fn [:shift] :left)} + {:key :j + :action (key-fn [:shift] :down)} + {:key :k + :action (key-fn [:shift] :up)} + {:key :l + :action (key-fn [:shift] :right)} + {:key :y + :action (key-fn [:cmd] :c)} + {:key :x + :action (key-fn nil :delete)} + {:key :c + :action (fn [] + (keystroke [] :delete) + (insert))} + {:key :b + :action (key-fn [:shift :alt] :left)} + {:key :w + :action (fn [] + (keystroke [:shift :alt] :right) + (keystroke [:shift] :right))} + {:key :0 + :action (key-fn [:shift :cmd] :left)} + {:mods [:shift] + :key :4 + :action (key-fn [:shift :cmd] :right)}]}) + +(fn create-screen-watcher + [f] + (let [watcher (hs.screen.watcher.newWithActiveScreen f)] + (: watcher :start) + (fn destroy [] + (: watcher :stop)))) + +(fn state-box + [label] + (let [frame (: (hs.screen.mainScreen) :fullFrame) + x frame.x + y frame.y + width frame.w + height frame.h + coords {:x (+ x (- width shape.w)) + :y (+ y (- height shape.h)) + :h shape.h + :w shape.w}] + (: box :setFillColor {:hex "#000" + :alpha 0.8}) + (: box :setFill true) + (: text :setTextColor {:hex "#FFF" + :alpha 1.0}) + (: text :setFrame coords) + (: box :setFrame coords) + (: text :setText label) + (if (= label :Normal) + (: text :setTextColor {:hex "#999" + :alpha 0.8}) + (= label :Insert) + (: text :setTextColor {:hex "#0F0" + :alpha 0.8}) + (= label :Visual) + (: text :setTextColor {:hex "#F0F" + :alpha 0.8})) + (: text :setTextStyle {:alignment :center}) + (: box :show) + (: text :show)) + box) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Side Effects +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn normal-mode + [state] + (state-box "Normal") + (call-when state.unbind-keys) + {:mode :normal + :unbind-keys (bind-keys bindings.normal) + }) + +(fn insert-mode + [] + (state-box "Insert") + (bind-keys bindings.insert)) + +(fn visual-mode + [] + (state-box "Visual") + (bind-keys bindings.visual)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Transitions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn disabled->normal + [state data] + (when (get-in [:config :vim :enabled] state) + (normal-mode state))) + +(fn normal->insert + [state data] + (call-when state.unbind-keys) + (call-when state.untap) + {:mode :insert + :unbind-keys (insert-mode)}) + +(fn normal->visual + [state data] + (call-when state.unbind-keys) + (call-when state.untap) + {:mode :visual + :unbind-keys (visual-mode)}) + +(fn ->disabled + [state data] + (call-when state.unbind-keys) + (call-when state.untap) + {:mode :disabled + :unbind-keys :nil}) + +(fn insert->normal + [state data] + (normal-mode state)) + +(fn visual->normal + [state data] + (normal-mode state)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; States +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local states + {:disabled {:enable disabled->normal} + :normal {:insert normal->insert + :visual normal->visual + :disable ->disabled} + :insert {:normal insert->normal + :disable ->disabled} + :visual {:normal visual->normal + :disable ->disabled}}) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Watchers & Logging +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn log-updates + [fsm] + (atom.add-watch fsm.state :logger + (fn [state] + (log.f "Vim mode: %s" state.mode)))) + +(fn watch-screen + [fsm active-screen-changed] + (let [state (atom.deref fsm.state)] + (when (~= state.mode :disabled) + (state-box state.mode)))) + +;; (fn log-key +;; [event] +;; (let [key-code (: event :getKeyCode) +;; flags (: event :getFlags) +;; key-char (. hs.keycodes.map key-code)] +;; (values false {}))) + +;; (let [types hs.eventtap.event.types +;; tap (hs.eventtap.new [types.keyDown] +;; log-key)] +;; (: tap :start)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialize +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn init + [config] + (let [initial {:config config + :mode :disabled + :unbind-keys nil} + state-machine (machine.new states initial :mode) + stop-screen-watcher (create-screen-watcher + (partial watch-screen state-machine))] + (set fsm state-machine) + (log-updates fsm) + (when (get-in [:vim :enabled] config) + (enable)) + (fn [] + (stop-screen-watcher)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +{:init init + :disable disable + :enable enable} diff --git a/windows.fnl b/windows.fnl index 47cc7c9..621c5d4 100644 --- a/windows.fnl +++ b/windows.fnl @@ -1,6 +1,19 @@ -(global undo {}) +(local {:filter filter} (require :lib.functional)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Config +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(fn undo.push [self] +(hs.grid.setMargins [0 0]) +(hs.grid.setGrid "3x2") + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; History +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(global history {}) + +(fn history.push + [self] (let [win (hs.window.focusedWindow) id (: win :id) tbl (. self id)] @@ -12,7 +25,8 @@ (when (~= last-el (: win :frame)) (table.insert tbl (: win :frame)))))))) -(fn undo.pop [self] +(fn history.pop + [self] (let [win (hs.window.focusedWindow) id (: win :id) tbl (. self id)] @@ -26,15 +40,17 @@ (alert (.. num-of-undos " undo steps available")))) (alert "nothing to undo")))))) -(fn jump-to-last-window [fsm] - (let [utils (require :utils)] - (-> (utils.globalFilter) - (: :getWindows hs.window.filter.sortByFocusedLast) - (. 2) - (: :focus)) - (: fsm :toIdle))) +(fn undo + [] + (: history :pop)) + -(fn highlight-active-window [] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Shared Functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn highlight-active-window + [] (let [rect (hs.drawing.rectangle (: (hs.window.focusedWindow) :frame))] (: rect :setStrokeColor {:red 1 :blue 0 :green 1 :alpha 1}) (: rect :setStrokeWidth 5) @@ -42,136 +58,25 @@ (: rect :show) (hs.timer.doAfter .3 (fn [] (: rect :delete))))) -(fn maximize-window-frame [fsm] - (: undo :push) +(fn maximize-window-frame + [] + (: history :push) (: (hs.window.focusedWindow) :maximize 0) - (highlight-active-window) - (: fsm :toIdle)) + (highlight-active-window)) -(fn center-window-frame [fsm] - (: undo :push) +(fn center-window-frame + [] + (: history :push) (let [win (hs.window.focusedWindow)] (: win :maximize 0) (hs.grid.resizeWindowThinner win) (hs.grid.resizeWindowShorter win) (: win :centerOnScreen)) - (highlight-active-window) - (when fsm - (: fsm :toIdle))) - -(local - arrow-map - {:k {:half [0 0 1 .5] :movement [ 0 -20] :complement :h :resize "Shorter"} - :j {:half [0 .5 1 .5] :movement [ 0 20] :complement :l :resize "Taller"} - :h {:half [0 0 .5 1] :movement [-20 0] :complement :j :resize "Thinner"} - :l {:half [.5 0 .5 1] :movement [ 20 0] :complement :k :resize "Wider"}}) - -(fn rect [rct] - (: undo :push) - (let [win (hs.window.focusedWindow)] - (when win (: win :move rct)))) + (highlight-active-window)) -(fn window-jump [modal fsm arrow] - (let [dir {:h "West" :j "South" :k "North" :l "East"}] - (: modal :bind [:ctrl] - arrow - (fn [] - (let [slf (-> (hs.window.focusedWindow) - (. :filter) - (. :defaultCurrentSpace)) - fun (->> (. dir arrow) - (.. :focusWindow) - (. slf))] - (fun slf nil true true) - (highlight-active-window)))))) - -(fn resize-window [modal arrow] - (let [dir {:h "Left" :j "Down" :k "Up" :l "Right"}] - ;; screen halves - (: modal :bind nil arrow - (fn [] - (: undo :push) - (rect (. (. arrow-map arrow) :half)))) - - ;; hs.grid.pushWindowUp/Down/Left/Right - (: modal :bind [:alt] arrow - (fn [] - (: undo :push) - (when (or (= arrow :h) (= arrow :l)) - (hs.grid.resizeWindowThinner (hs.window.focusedWindow))) - (when (or (= arrow :j) (= arrow :k)) - (hs.grid.resizeWindowShorter (hs.window.focusedWindow))) - (let [gridFn (->> (. dir arrow) - (.. :pushWindow) - (. hs.grid))] - (gridFn (hs.window.focusedWindow))))) - - ;; hs.grid.resizeWindowShorter/Taller/Thinner/Wider - (: modal :bind - [:shift] - arrow - (fn [] - (: undo :push) - (let [dir (-> arrow-map (. arrow) (. :resize)) - gridFn (->> dir (.. :resizeWindow) (. hs.grid))] - (gridFn (hs.window.focusedWindow))))))) -(hs.grid.setMargins [0 0]) -(hs.grid.setGrid "3x2") - -(fn show-grid [fsm] - ;; todo: undo - (: undo :push) - (hs.grid.show) - (: fsm :toIdle)) - -(fn bind [hotkeyMmodal fsm] - ;; maximize window - (: hotkeyMmodal :bind nil :m (partial maximize-window-frame fsm)) - - ;; center window - (: hotkeyMmodal :bind nil :c (partial center-window-frame fsm)) - - ;; undo last thing - (: hotkeyMmodal :bind nil :u (fn [] (: undo :pop))) - - ;; moving/re-sizing windows - (hs.fnutils.each - [:h :l :k :j] - (hs.fnutils.partial resize-window hotkeyMmodal)) - - ;; window grid - (: hotkeyMmodal :bind nil :g (hs.fnutils.partial show-grid fsm)) - - ;; jumping between windows - (hs.fnutils.each - [:h :l :k :j] - (hs.fnutils.partial window-jump hotkeyMmodal fsm)) - - ;; quick jump to the last window - (: hotkeyMmodal :bind nil :w - (hs.fnutils.partial jump-to-last-window fsm)) - - ;; moving windows between monitors - (: hotkeyMmodal :bind nil :p - (fn [] - ;; todo: undo:push - (: (hs.window.focusedWindow) :moveOneScreenNorth nil true))) - (: hotkeyMmodal :bind nil :n - (fn [] - ;; todo: undo: push - (: (hs.window.focusedWindow) :moveOneScreenSouth nil true))) - (: hotkeyMmodal :bind [:shift] :n - (fn [] - ;; todo: undo: push - (: (hs.window.focusedWindow) :moveOneScreenWest nil true))) - (: hotkeyMmodal :bind [:shift] :p - (fn [] - ;; todo: undo: push - (: (hs.window.focusedWindow) :moveOneScreenEast nil true)))) - - -(fn activate-app [app-name] +(fn activate-app + [app-name] (hs.application.launchOrFocus app-name) (let [app (hs.application.find app-name)] (when app @@ -179,7 +84,8 @@ (hs.timer.doAfter .05 highlight-active-window) (: app :unhide)))) -(fn set-mouse-cursor-at [app-title] +(fn set-mouse-cursor-at + [app-title] (let [sf (: (: (hs.application.find app-title) :focusedWindow) :frame) desired-point (hs.geometry.point (- (+ sf._x sf._w) (/ sf._w 2)) @@ -187,26 +93,227 @@ (/ sf._h 2)))] (hs.mouse.setAbsolutePosition desired-point))) -(fn add-state [modal] - (modal.add-state - :windows - {:from :* - :init (fn [self fsm] - (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text "cmd + hjkl \t jumping\nhjkl \t\t\t\t halves\nalt + hjkl \t\t increments\nshift + hjkl \t resize\nn, p \t next, prev screen\ng \t\t\t\t\t grid\nm \t\t\t\t maximize\nu \t\t\t\t\t undo") +(fn show-grid + [] + (: history :push) + (hs.grid.show)) + +(fn jump-to-last-window + [] + (let [utils (require :lib.utils)] + (-> (utils.globalFilter) + (: :getWindows hs.window.filter.sortByFocusedLast) + (. 2) + (: :focus)))) + - (modal.bind - self - [:cmd] :space - (fn [] (: fsm :toMain))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Jumping Windows +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - (bind self.hotkeyModal fsm) +(fn jump-window + [arrow] + (let [dir {:h "West" :j "South" :k "North" :l "East"} + space (. (hs.window.focusedWindow) :filter :defaultCurrentSpace) + fn-name (.. :focusWindow (. dir arrow))] + (: space fn-name nil true true) + (highlight-active-window))) - (: self.hotkeyModal :enter))})) +(fn jump-window-left + [] + (jump-window :h)) -{:add-state add-state - :activate-app activate-app - :set-mouse-cursor-at set-mouse-cursor-at - :maximize-window-frame maximize-window-frame +(fn jump-window-above + [] + (jump-window :j)) + +(fn jump-window-below + [] + (jump-window :k)) + +(fn jump-window-right + [] + (jump-window :l)) + +(fn allowed-app? + [window] + (if (: window :isStandard) + true + false)) + +(fn jump [] + (let [wns (->> (hs.window.allWindows) + (filter allowed-app?))] + (hs.hints.windowHints wns nil true))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Movement\Resizing Constants +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(local + arrow-map + {:k {:half [0 0 1 .5] :movement [ 0 -20] :complement :h :resize "Shorter"} + :j {:half [0 .5 1 .5] :movement [ 0 20] :complement :l :resize "Taller"} + :h {:half [0 0 .5 1] :movement [-20 0] :complement :j :resize "Thinner"} + :l {:half [.5 0 .5 1] :movement [ 20 0] :complement :k :resize "Wider"}}) + +(fn grid + [method direction] + (let [fn-name (.. method direction) + f (. hs.grid fn-name)] + (f (hs.window.focusedWindow)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Resize window by half +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn rect [rct] + (: history :push) + (let [win (hs.window.focusedWindow)] + (when win (: win :move rct)))) + +(fn resize-window-halve + [arrow] + (: history :push) + (rect (. arrow-map arrow :half))) + +(fn resize-half-left + [] + (resize-window-halve :h)) + +(fn resize-half-right + [] + (resize-window-halve :l)) + +(fn resize-half-top + [] + (resize-window-halve :k)) + +(fn resize-half-bottom + [] + (resize-window-halve :j)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Resize window by increments +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn resize-by-increment + [arrow] + (let [directions {:h "Left" + :j "Down" + :k "Up" + :l "Right"}] + (: history :push) + (when (or (= arrow :h) (= arrow :l)) + (hs.grid.resizeWindowThinner (hs.window.focusedWindow))) + (when (or (= arrow :j) (= arrow :k)) + (hs.grid.resizeWindowShorter (hs.window.focusedWindow))) + (grid :pushWindow (. directions arrow)))) + +(fn resize-inc-left + [] + (resize-by-increment :h)) + +(fn resize-inc-bottom + [] + (resize-by-increment :j)) + +(fn resize-inc-top + [] + (resize-by-increment :k)) + +(fn resize-inc-right + [] + (resize-by-increment :l)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Resize windows +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn resize-window + [arrow] + (: history :push) + ;; hs.grid.resizeWindowShorter/Taller/Thinner/Wider + (grid :resizeWindow (. arrow-map arrow :resize))) + +(fn resize-left + [] + (resize-window :h)) + +(fn resize-up + [] + (resize-window :j)) + +(fn resize-down + [] + (resize-window :k)) + +(fn resize-right + [] + (resize-window :l)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Move to screen +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn move-screen + [method] + (let [window (hs.window.focusedWindow)] + (: window method nil true))) + +(fn move-north + [] + (move-screen :moveOneScreenNorth)) + +(fn move-south + [] + (move-screen :moveOneScreenSouth)) + +(fn move-east + [] + (move-screen :moveOneScreenEast)) + +(fn move-west + [] + (move-screen :moveOneScreenWest)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Exports +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +{:activate-app activate-app :center-window-frame center-window-frame - :highlight-active-window highlight-active-window} + :highlight-active-window highlight-active-window + :jump jump + :jump-to-last-window jump-to-last-window + :jump-window-left jump-window-left + :jump-window-above jump-window-above + :jump-window-below jump-window-below + :jump-window-right jump-window-right + :maximize-window-frame maximize-window-frame + :move-east move-east + :move-north move-north + :move-south move-south + :move-west move-west + :rect rect + :resize-half-bottom resize-half-bottom + :resize-half-left resize-half-left + :resize-half-right resize-half-right + :resize-half-top resize-half-top + :resize-inc-left resize-inc-left + :resize-inc-bottom resize-inc-bottom + :resize-inc-top resize-inc-top + :resize-inc-right resize-inc-right + :resize-left resize-left + :resize-up resize-up + :resize-down resize-down + :resize-right resize-right + :set-mouse-cursor-at set-mouse-cursor-at + :show-grid show-grid + :undo undo} From 113d2a204b6279347a5b38ebe06cd7e42875311c Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 12 Aug 2019 11:59:23 -0400 Subject: [PATCH 03/34] Add local modal items to main menus --- config.fnl | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/config.fnl b/config.fnl index e958b29..d6bcec9 100644 --- a/config.fnl +++ b/config.fnl @@ -330,17 +330,19 @@ :action "chrome:open-location"} {:mods [:cmd] :key :k - :action "chrome:prev-tab" + :action "chrome:next-tab" :repeat true} {:mods [:cmd] :key :j - :action "chrome:next-tab" + :action "chrome:prev-tab" :repeat true}]) (local browser-items - [{:key "'" - :title "Edit with Emacs" - :action "emacs:edit-with-emacs"}]) + (concat + menu-items + [{:key "'" + :title "Edit with Emacs" + :action "emacs:edit-with-emacs"}])) (local brave-config {:key "Brave Browser" @@ -362,21 +364,24 @@ (local grammarly-config {:key "Grammarly" - :launch (fn []) - :items [{:mods [:ctrl] - :key :c - :title "Return to Emacs" - :action "grammarly:back-to-emacs"}] + :items (concat + menu-items + [{:mods [:ctrl] + :key :c + :title "Return to Emacs" + :action "grammarly:back-to-emacs"}]) :keys ""}) (local hammerspoon-config {:key "Hammerspoon" - :items [{:key :r - :title "Reload Console" - :action hs.reload} - {:key :c - :title "Clear Console" - :action hs.console.clearConsole}] + :items (concat + menu-items + [{:key :r + :title "Reload Console" + :action hs.reload} + {:key :c + :title "Clear Console" + :action hs.console.clearConsole}]) :keys []}) (local slack-config From 7e39aa889502d18e2e01fd77228ed7b2d2e07a24 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 12 Aug 2019 12:00:29 -0400 Subject: [PATCH 04/34] Removed vim binding to disable vim when ESC is pressed in normal --- vim.fnl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vim.fnl b/vim.fnl index ed54b7c..e0d0f74 100644 --- a/vim.fnl +++ b/vim.fnl @@ -84,9 +84,7 @@ (fn [] (keystroke target-mods target-key))) (local bindings - {:normal [{:key :ESCAPE - :action disable} - {:key :h + {:normal [{:key :h :action (key-fn [] :left) :repeat true} {:key :j From fc006050bc9defe5fa3d4faba1a3dbab72804f1c Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 12 Aug 2019 12:21:32 -0400 Subject: [PATCH 05/34] Replaced menu leader lines with spaces --- lib/text.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/text.fnl b/lib/text.fnl index 07d4191..bc83271 100644 --- a/lib/text.fnl +++ b/lib/text.fnl @@ -24,7 +24,7 @@ (let [max (max-length items)] (map (fn [[key action]] - (.. (pad-str "." (+ max 1) (.. key " ")) "..... " action)) + (.. (pad-str " " max key) " " action)) items))) {:align-columns align-columns} From 628133c3936b850047978d7630a558c19d82ff62 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 12 Aug 2019 15:57:06 -0400 Subject: [PATCH 06/34] Added windows grid dimensions to config --- core.fnl | 1 + windows.fnl | 29 ++++++++++++++--------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/core.fnl b/core.fnl index defb464..2c6b921 100644 --- a/core.fnl +++ b/core.fnl @@ -110,6 +110,7 @@ (local modules [:lib.hyper :vim + :windows :lib.bind :lib.modal :lib.apps]) diff --git a/windows.fnl b/windows.fnl index db8c3fd..12149d9 100644 --- a/windows.fnl +++ b/windows.fnl @@ -1,10 +1,6 @@ -(local {:filter filter} (require :lib.functional)) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Config -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(local {:filter filter + :get-in get-in} (require :lib.functional)) -(hs.grid.setMargins [0 0]) -(hs.grid.setGrid "3x2") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; History @@ -44,14 +40,6 @@ [] (: history :pop)) -(fn jump-to-last-window [fsm] - (let [utils (require :utils)] - (-> (utils.globalFilter) - (: :getWindows hs.window.filter.sortByFocusedLast) - (. 2) - (: :focus)) - (when fsm (: fsm :toIdle)))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Shared Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -290,11 +278,22 @@ (move-screen :moveOneScreenWest)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn init + [config] + (hs.grid.setMargins (or (get-in [:grid :margins] config) [0 0])) + (hs.grid.setGrid (or (get-in [:grid :size] config) "3x2"))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -{:activate-app activate-app +{:init init + :activate-app activate-app :center-window-frame center-window-frame :highlight-active-window highlight-active-window :jump jump From 7d142416360b90e32154ec3baeca85d60ae7ad1e Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Mon, 2 Sep 2019 12:20:40 -0700 Subject: [PATCH 07/34] Cleanup emacs.fnl file for refactor --- emacs.fnl | 36 ++++------ lib/text.fnl | 10 +-- old/emacs.lua | 94 ------------------------- old/modal.lua | 97 -------------------------- old/preview-app.lua | 38 ----------- old/slack.lua | 88 ------------------------ old/windows.lua | 163 -------------------------------------------- 7 files changed, 17 insertions(+), 509 deletions(-) delete mode 100644 old/emacs.lua delete mode 100644 old/modal.lua delete mode 100644 old/preview-app.lua delete mode 100644 old/slack.lua delete mode 100644 old/windows.lua diff --git a/emacs.fnl b/emacs.fnl index df7587a..9b932bf 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -15,6 +15,8 @@ (: log :i run-str) (: timer :start))) +(fn note [] (capture true)) + ;; executes emacsclient, evaluating special function that must be present in ;; Emacs config, passing pid and title of the caller app, along with display id ;; where the screen of the caller app is residing @@ -47,17 +49,8 @@ (: edit-window :moveToScreen scr) (: windows :center-window-frame)))) -(fn run-emacs-fn - ;; executes given elisp function via emacsclient, if args table present passes - ;; them to the function - [elisp-fn args] - (let [args-lst (when args (.. " '" (table.concat args " '"))) - run-str (.. "/usr/local/bin/emacsclient" - " -e \"(funcall '" elisp-fn - (if args-lst args-lst "") - ")\"")] - (: log :i run-str) - (io.popen run-str))) +;; global keybinging to invoke edit-with-emacs feature +(local edit-with-emacs-key (hs.hotkey.new [:cmd :ctrl] :o nil edit-with-emacs)) (fn run-emacs-fn ;; executes given elisp function via emacsclient, if args table present passes @@ -68,6 +61,7 @@ " -e \"(funcall '" elisp-fn (if args-lst args-lst "") ")\"")] + (: log :i run-str) (io.popen run-str))) (fn full-screen @@ -117,17 +111,6 @@ (: fsm :toIdle) (full-screen)))) -;; adds Emacs modal state to the FSM instance -(fn add-state [modal] - (modal.add-state - :emacs - {:from :* - :init (fn [self fsm] - (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text "c \tcapture\nz\tnote\nf\tfullscreen\nv\tsplit") - (bind self.hotkeyModal fsm) - (: self.hotkeyModal :enter))})) - ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/switch-to-app` in spacehammer.el (fn switch-to-app [pid] @@ -144,7 +127,11 @@ (: app :selectMenuItem [:Edit :Paste])))) -;; Post refactor +(fn disable-edit-with-emacs [] + (: edit-with-emacs-key :disable)) + +(fn enable-edit-with-emacs [] + (: edit-with-emacs-key :enable)) (fn maximize [] @@ -181,7 +168,8 @@ :switchToApp switch-to-app :switchToAppAndPasteFromClipboard switch-to-app-and-paste-from-clipboard :editWithEmacsCallback edit-with-emacs-callback - ;; Post refactor + :enable-edit-with-emacs enable-edit-with-emacs + :disable-edit-with-emacs disable-edit-with-emacs :capture capture :maximize maximize :note note diff --git a/lib/text.fnl b/lib/text.fnl index bc83271..7abd495 100644 --- a/lib/text.fnl +++ b/lib/text.fnl @@ -1,9 +1,10 @@ -(local {:map map - :merge merge +(local {:map map + :merge merge :reduce reduce} (require :lib.functional)) -;; Menu Column Alignment -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Menu Column Alignment ;; +;;;;;;;;;;;;;;;;;;;;;;;;;;; (fn max-length [items] @@ -12,7 +13,6 @@ 0 items)) - (fn pad-str [char max str] (let [diff (- max (# str))] diff --git a/old/emacs.lua b/old/emacs.lua deleted file mode 100644 index 072071a..0000000 --- a/old/emacs.lua +++ /dev/null @@ -1,94 +0,0 @@ -local emacs = {} - -local capture = function(isNote) - local key = "" - if isNote then - key = "\"z\"" -- key is a string associated with org-capture template - end - local currentApp = hs.window.focusedWindow(); - local pid = "\"" .. currentApp:pid() .. "\" " - local title = "\"" .. currentApp:title() .. "\" " - local runStr = "/usr/local/bin/emacsclient" .. " -c -F '(quote (name . \"capture\"))'" .. " -e '(activate-capture-frame " .. pid .. title .. key .. " )'" - - hs.timer.delayed.new(0.1, function() io.popen(runStr) end):start() -end - -local bind = function(hotkeyModal, fsm) - hotkeyModal:bind ("", "c", function() - fsm:toIdle() - capture() end) - hotkeyModal:bind("", "z", function() - fsm:toIdle() - capture(true) -- note on currently clocked in - end) -end - --- don't remove - this is callable from Emacs -emacs.switchToApp = function(pid) - local app = hs.application.applicationForPID(pid) - if app then - app:activate() - end -end - --- don't remove - this is callable from Emacs -emacs.switchToAppAndPasteFromClipboard = function(pid) - local app = hs.application.applicationForPID(pid) - if app then - app:activate() - app:selectMenuItem({"Edit", "Paste"}) - end -end - -emacs.addState = function(modal) - modal.addState("emacs", { - from = "*", - init = function(self, fsm) - self.hotkeyModal = hs.hotkey.modal.new() - modal.displayModalText "c \tcapture\nz\tnote" - - self.hotkeyModal:bind("","escape", function() fsm:toIdle() end) - self.hotkeyModal:bind({"cmd"}, "space", nil, function() fsm:toMain() end) - - bind(self.hotkeyModal, fsm) - self.hotkeyModal:enter() - end}) -end - -emacs.editWithEmacs = function() - local currentApp = hs.window.focusedWindow():application() - hs.eventtap.keyStroke({"cmd"}, "a") - hs.eventtap.keyStroke({"cmd"}, "c") - - local pid = "\"" .. currentApp:pid() .. "\" " - local title = "\"" .. currentApp:title() .. "\" " - - local runStr = "/usr/local/bin/emacsclient" .. " -c -F '(quote (name . \"edit\"))'" .. " -e '(ag/edit-with-emacs" .. pid .. title .. " )'"; - - io.popen(runStr) -end - -emacs.enableEditWithEmacs = function() - emacs.editWithEmacsKey = - emacs.editWithEmacsKey or hs.hotkey.new({"cmd", "ctrl"}, "o", nil, emacs.editWithEmacs) - emacs.editWithEmacsKey:enable() -end - -emacs.disableEditWithEmacs = function() - emacs.editWithEmacsKey:disable() -end - --- whenever I connect to a different display my Emacs frame gets screwed. This is a temporary fix (until) I figure out Display Profiles feature --- you can find the relevant elisp function here: https://github.com/agzam/dot-spacemacs/blob/master/layers/ag-general/funcs.el#L36 -local function fixEmacsFrame() - io.popen("/usr/local/bin/emacsclient" .. " -e '(ag/fix-frame)'") -end - -hs.screen.watcher.newWithActiveScreen(function(isActiveScreenChanged) - if isActiveScreenChanged == nil then - hs.alert("Screen watcher") - fixEmacsFrame() - end -end):start() - -return emacs diff --git a/old/modal.lua b/old/modal.lua deleted file mode 100644 index 497146b..0000000 --- a/old/modal.lua +++ /dev/null @@ -1,97 +0,0 @@ -local modal = {} -local stateMachine = require "statemachine" -local utils = require "utils" -local windows = require "windows" - --- local log = hs.logger.new('modal-module','debug') - -modal.displayModalText = function(txt) - hs.alert.closeAll() - alert(txt, 999999) -end - -modal.exitAllModals = function() - hs.fnutils.each(modal.states, function(s) - if s.hotkeyModal then s.hotkeyModal:exit() end - end) -end - -modal.addState = function(name,state) - modal.states[name] = state -end - -local filterAllowedApps = function(w) - local allowedApps = {"Emacs", "iTerm2"} - if (not w:isStandard()) and (not hs.fnutils.contains(allowedApps, w:application():name())) then - return false; - end - return true; -end - -modal.states = { - idle = { - from = "*", to = "idle", - callback = function(self, event, from, to) - hs.alert.closeAll() - modal.exitAllModals() - end - }, - main = { - from = "*", to = "main", - init = function(self, fsm) - if self.hotkeyModal then - self.hotkeyModal:enter() - else - self.hotkeyModal = hs.hotkey.modal.new({"cmd"}, "space") - end - self.hotkeyModal:bind("","space", nil, function() fsm:toIdle(); windows.activateApp("Alfred 3") end) - self.hotkeyModal:bind("","w", nil, function() fsm:toWindows() end) - self.hotkeyModal:bind("","a", nil, function() fsm:toApps() end) - self.hotkeyModal:bind("", "m", nil, function() fsm:toMedia() end) - self.hotkeyModal:bind("", "x", nil, function() fsm:toEmacs() end) - self.hotkeyModal:bind("","j", nil, function() - local wns = hs.fnutils.filter(hs.window.allWindows(), filterAllowedApps) - hs.hints.windowHints(wns, nil, true) - fsm:toIdle() end) - - self.hotkeyModal:bind("","escape", function() fsm:toIdle() end) - self.hotkeyModal:bind("","q", function() fsm:toIdle() end) - self.hotkeyModal:bind("ctrl","[", function() fsm:toIdle() end) - self.hotkeyModal:bind("ctrl","g", function() fsm:toIdle() end) - - function self.hotkeyModal:entered() - modal.displayModalText "w \t- windows\na \t- apps\n j \t- jump\nm - media\nx\t- emacs" - end - end - } -} - --- -- each modal has: name, init function -modal.createMachine = function() - local events = {} - local callbacks = {} - - for k, s in pairs (modal.states) do - table.insert(events, { name = "to" .. utils.capitalize(k), - from = s.from or {"main", "idle"}, - to = s.to or k}) - end - - for k, s in pairs (modal.states) do - if s.callback then - cFn = s.callback - else - cFn = function(self, event, from, to) - local st = modal.states[to] - st.init(st, self) - end - end - callbacks["on" .. k] = cFn - end - - return stateMachine.create({ initial = "idle", - events = events, - callbacks = callbacks}) -end - -return modal diff --git a/old/preview-app.lua b/old/preview-app.lua deleted file mode 100644 index bf3e836..0000000 --- a/old/preview-app.lua +++ /dev/null @@ -1,38 +0,0 @@ -local windows = require "windows" -local keybindings = require "keybindings" - -local previewAppHotKeys = {} - --- j/k for scrolling up and down -for k, dir in pairs({j = -3, k = 3}) do - local function scrollFn() - windows.setMouseCursorAtApp("Preview") - hs.eventtap.scrollWheel({0, dir}, {}) - end - table.insert(previewAppHotKeys, hs.hotkey.new("", k, scrollFn, nil, scrollFn)) -end - -for k, dir in pairs({n = "down", p = "up"}) do - local function scrollPage() - hs.eventtap.keyStroke({"shift"}, dir) - end - table.insert(previewAppHotKeys, hs.hotkey.new("", k, scrollPage, nil, scrollPage)) -end - -for k, dir in pairs({o = "Previous", i = "Next"}) do - local function previousOrNextPage() - local app = hs.window.focusedWindow():application() - app:selectMenuItem({"Go", dir .. " Item"}) - end - table.insert(previewAppHotKeys, hs.hotkey.new({"ctrl"}, k, previousOrNextPage, nil, previousOrNextPage)) -end - --- enable/disable hotkeys -keybindings.appSpecific["Preview"] = { - activated = function() - for _,k in pairs(previewAppHotKeys) do - keybindings.activateAppKey("Preview", k) - end - end, - deactivated = function() keybindings.deactivateAppKeys("Preview") end, -} diff --git a/old/slack.lua b/old/slack.lua deleted file mode 100644 index 056c7e9..0000000 --- a/old/slack.lua +++ /dev/null @@ -1,88 +0,0 @@ --- This module is to improve keyboard oriented workflow in Slack -local windows = require "windows" -local keybindings = require "keybindings" -local module = {} - -local function setMouseCursorOnSlack() - windows.setMouseCursorAtApp("Slack") -end - -local slackLocalHotkeys = { - -- jump to end of thread on C-g - hs.hotkey.bind({"alt"}, "g", - function() - setMouseCursorOnSlack() - -- from my experience this number is big enough to take you to the end of thread - hs.eventtap.scrollWheel({0, -5000}, {}) - end, nil, nil), - -- add a reaction - hs.hotkey.bind({"alt"}, "r", - function() - hs.eventtap.keyStroke({"cmd", "shift"}, "\\") - end, nil, nil), - -- start a thread - hs.hotkey.bind({"alt"}, "t", - function() - hs.eventtap.keyStroke({"shift"}, "tab") - hs.eventtap.keyStroke(nil, "up") - hs.eventtap.keyStroke(nil, "tab") - hs.eventtap.keyStroke(nil, "tab") - hs.eventtap.keyStroke(nil, "tab") - hs.eventtap.keyStroke(nil, "return") - end, nil, nil) -} - --- Slack client doesn't allow convenient method to scrolling in thread with keyboard --- adding C-j|C-e, C-k|C-y bindings for scrolling up and down -for k,dir in pairs({j = -3, k = 3, e = -3, y = 3}) do - local function scrollFn() - -- to correctly scroll in Slack's thread window, the mouse pointer have to be within its frame (otherwise it would scroll things in whatever app cursor is currently pointing at) - setMouseCursorOnSlack() - hs.eventtap.scrollWheel({0, dir}, {}) - end - - table.insert(slackLocalHotkeys, hs.hotkey.new({"ctrl"}, k, scrollFn, nil, scrollFn)) -end - --- Alt-o/Alt-i for back and forth in history -for k, dir in pairs({o = "[", i = "]"}) do - local back_forward = function() hs.eventtap.keyStroke({"Cmd"}, dir) end - table.insert(slackLocalHotkeys, hs.hotkey.new({"Alt"}, k, back_forward, nil, back_forward)) -end - --- C-n|C-p - for up and down (instead of using arrow keys) -for k, dir in pairs({p = "up", n = "down"}) do - local function upNdown() - hs.eventtap.keyStroke({}, dir) - end - - table.insert(slackLocalHotkeys, hs.hotkey.new({"ctrl"}, k, upNdown, nil, upNdown)) -end - --- enable/disable hotkeys -keybindings.appSpecific["Slack"] = { - activated = function() - for _,k in pairs(slackLocalHotkeys) do - keybindings.activateAppKey("Slack", k) - end - end, - deactivated = function() keybindings.deactivateAppKeys("Slack") end -} - -module.bind = function(modal, fsm) - -- Open "Jump to dialog immediately after jumping to Slack GUI through `Apps` modal" - modal:bind("", "s", function() - hs.application.launchOrFocus("/Applications/Slack.app") - local app = hs.application.find("Slack") - if app then - app:activate() - hs.timer.doAfter(0.2, windows.highlighActiveWin) - hs.eventtap.keyStroke({"cmd"}, "t") - app:unhide() - end - - fsm:toIdle() - end) -end - -return module diff --git a/old/windows.lua b/old/windows.lua deleted file mode 100644 index 5297abb..0000000 --- a/old/windows.lua +++ /dev/null @@ -1,163 +0,0 @@ -local windows = {} -local utils = require "utils" - --- hs.window.setFrameCorrectness = true - --- undo for window operations -undo = {} - --- define window movement/resize operation mappings -local arrowMap = { - k = { half = { 0, 0, 1,.5}, movement = { 0,-20}, complement = "h", resize = "Shorter" }, - j = { half = { 0,.5, 1,.5}, movement = { 0, 20}, complement = "l", resize = "Taller" }, - h = { half = { 0, 0,.5, 1}, movement = {-20, 0}, complement = "j", resize = "Thinner" }, - l = { half = {.5, 0,.5, 1}, movement = { 20, 0}, complement = "k", resize = "Wider" }, -} - --- compose screen quadrants from halves -local function quadrant(t1, t2) - return {t1[1] + t2[1], t1[2] + t2[2], .5, .5} -end - --- move and/or resize windows -local function rect(rect) - return function() - undo:push() - local win = fw() - if win then win:move(rect) end - end -end - -local function windowJump(modal, fsm, arrow) - local dir = { h = "West", j = "South", k = "North", l = "East"} - modal:bind({"ctrl"}, arrow, function() - slf = fw().filter.defaultCurrentSpace - local fn = slf["focusWindow"..dir[arrow]] - fn(slf, nil, true, true) - windows.highlighActiveWin() - fsm:toIdle() - end) -end - -local function jumpToLastWindow(fsm) - utils.globalFilter():getWindows(hs.window.filter.sortByFocusedLast)[2]:focus() - fsm:toIdle() -end - -local function maximizeWindowFrame() - undo:push() - fw():maximize(0) - windows.highlighActiveWin() -end - -local function resizeWindow(modal, arrow) - local dir = { h = "Left", j = "Down", k = "Up", l = "Right"} - -- screen halves - modal:bind({}, arrow, function() - undo:push() - rect(arrowMap[arrow].half)() - end) - -- local thinShort = { h = , } - -- incrementally - modal:bind({"alt"}, arrow, function() - undo:push() - if arrow == "h" or arrow == "l" then - hs.grid.resizeWindowThinner(fw()) - end - if arrow == "j" or arrow == "k" then - hs.grid.resizeWindowShorter(fw()) - end - hs.grid['pushWindow'..dir[arrow]](fw()) - end) - - modal:bind({"shift"}, arrow, function() - undo:push() - hs.grid['resizeWindow'..arrowMap[arrow].resize](fw()) - end) -end - -hs.grid.setMargins({0, 0}) -hs.grid.setGrid("3x2") - -local function showGrid(fsm) - hs.grid.show() - fsm:toIdle() -end - -local bind = function(hotkeyMmodal, fsm) - -- maximize window - hotkeyMmodal:bind("","m", maximizeWindowFrame) - -- undo - hotkeyMmodal:bind("", "u", function() undo:pop() end) - -- moving/re-sizing windows - hs.fnutils.each({"h", "l", "k", "j"}, hs.fnutils.partial(resizeWindow, hotkeyMmodal)) - -- window grid - hotkeyMmodal:bind("", "g", hs.fnutils.partial(showGrid, fsm)) - -- jumping between windows - hs.fnutils.each({"h", "l", "k", "j"}, hs.fnutils.partial(windowJump, hotkeyMmodal, fsm)) - -- quick jump to the last window - hotkeyMmodal:bind({}, "w", hs.fnutils.partial(jumpToLastWindow, fsm)) - -- moving windows between monitors - hotkeyMmodal:bind({}, "p", function() undo:push(); fw():moveOneScreenNorth(nil, true) end) - hotkeyMmodal:bind({"shift"}, "n", function() undo:push(); fw():moveOneScreenWest(nil, true) end) - hotkeyMmodal:bind({"shift"}, "p", function() undo:push(); fw():moveOneScreenEast(nil, true) end) - hotkeyMmodal:bind({}, "n", function() undo:push(); fw():moveOneScreenSouth(nil, true) end) -end - -function undo:push() - local win = fw() - if win and not undo[win:id()] then - self[win:id()] = win:frame() - end -end - -function undo:pop() - local win = fw() - if win and self[win:id()] then - win:setFrame(self[win:id()]) - self[win:id()] = nil - end -end - --- in a short burst highlights the outer border of active window frame -windows.highlighActiveWin = function() - local rctgl = hs.drawing.rectangle(fw():frame()) - rctgl:setStrokeColor({["red"]=1, ["blue"]=0, ["green"]=1, ["alpha"]=1}) - rctgl:setStrokeWidth(5) - rctgl:setFill(false) - rctgl:show() - hs.timer.doAfter(0.3, function() rctgl:delete() end) -end - --- activates app by a given appName -windows.activateApp = function(appName) - hs.application.launchOrFocus(appName) - - local app = hs.application.find(appName) - if app then - app:activate() - hs.timer.doAfter(0.05, windows.highlighActiveWin) - app:unhide() - end -end - -windows.setMouseCursorAtApp = function(appTitle) - local sf = hs.application.find(appTitle):focusedWindow():frame() - local desired_point = hs.geometry.point(sf._x + sf._w - (sf._w * 0.10), sf._y + sf._h - (sf._h * 0.10)) - hs.mouse.setAbsolutePosition(desired_point) -end - -windows.addState = function(modal) - modal.addState("windows", - {from = "*", - init = function(self, fsm) - self.hotkeyModal = hs.hotkey.modal.new() - modal.displayModalText("cmd + hjkl \t jumping\nhjkl \t\t\t\t halves\nalt + hjkl \t\t increments\nshift + hjkl \t resize\nn, p \t next, prev screen\ng \t\t\t\t\t grid\nm \t\t\t\t maximize\nu \t\t\t\t\t undo") - self.hotkeyModal:bind("","escape", function() fsm:toIdle() end) - self.hotkeyModal:bind({"cmd"}, "space", nil, function() fsm:toMain() end) - bind(self.hotkeyModal, fsm) - self.hotkeyModal:enter() - end}) -end - -return windows From fc3907f6beba090533776eb7291570a05a9c1078 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 2 Sep 2019 18:54:13 -0400 Subject: [PATCH 08/34] Setup emacs to support enabling & disabling edit-with-emacs-key on app activation --- config.fnl | 22 ++++++++++++++++------ emacs.fnl | 3 ++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/config.fnl b/config.fnl index d6bcec9..4f12bcd 100644 --- a/config.fnl +++ b/config.fnl @@ -1,4 +1,7 @@ (local windows (require :windows)) +(local emacs (require :emacs)) +(local vim (require :vim)) + (local {:concat concat :logf logf} (require :lib.functional)) @@ -58,6 +61,12 @@ ;; [x] cmd-p - prev-app +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Initialize +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(emacs.enable-edit-with-emacs) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Actions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -314,10 +323,7 @@ :action "apps:next-app"} {:mods [:cmd] :key :p - :action "apps:prev-app"} - {:mods [:cmd :ctrl] - :key :o - :action "emacs:edit-with-emacs"}]) + :action "apps:prev-app"}]) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -356,8 +362,12 @@ (local emacs-config {:key "Emacs" - :activate "vim:disable" - :deactivate "vim:enable" + :activate (fn [] + (vim.disable) + (emacs.disable-edit-with-emacs)) + :deactivate (fn [] + (vim.enable) + (emacs.enable-edit-with-emacs)) :launch "emacs:maximize" :items [] :keys []}) diff --git a/emacs.fnl b/emacs.fnl index 9b932bf..711f0da 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -22,6 +22,7 @@ ;; where the screen of the caller app is residing (fn edit-with-emacs [] + (: log :i "Editing with emacs") (let [current-app (: (hs.window.focusedWindow) :application) pid (.. "\"" (: current-app :pid) "\"") title (.. "\"" (: current-app :title) "\"") @@ -50,7 +51,7 @@ (: windows :center-window-frame)))) ;; global keybinging to invoke edit-with-emacs feature -(local edit-with-emacs-key (hs.hotkey.new [:cmd :ctrl] :o nil edit-with-emacs)) +(local edit-with-emacs-key (hs.hotkey.bind [:cmd :ctrl] :o edit-with-emacs)) (fn run-emacs-fn ;; executes given elisp function via emacsclient, if args table present passes From faf2da442380133b2d433334112818d16a6cf5d5 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Mon, 2 Sep 2019 16:51:22 -0700 Subject: [PATCH 09/34] fix for Emacs capture --- config.fnl | 4 ++-- emacs.fnl | 20 ++++++++++---------- spacehammer.el | 26 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/config.fnl b/config.fnl index 4f12bcd..d1b4e67 100644 --- a/config.fnl +++ b/config.fnl @@ -274,10 +274,10 @@ [return {:key :c :title "Capture" - :action "emacs:capture"} + :action (fn [] (emacs.capture))} {:key :z :title "Note" - :action "emacs:note"} + :action (fn [] (emacs.note))} {:key :v :title "Split" :action "emacs:vertical-split-with-emacs"} diff --git a/emacs.fnl b/emacs.fnl index 711f0da..89422b1 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -1,17 +1,17 @@ (local log (hs.logger.new "emacs.fnl" "debug")) (fn capture - [is-note] - (let [key (if is-note "\"z\"" "") + [note?] + (let [key (if note? "\"z\"" "") current-app (hs.window.focusedWindow) - pid (.. "\"" (: current-app :pid) "\" ") - title (.. "\"" (: current-app :title) "\" ") - run-str (.. - "/usr/local/bin/emacsclient" - " -c -F '(quote (name . \"capture\"))'" - " -e '(activate-capture-frame " - pid title key " )'") - timer (hs.timer.delayed.new .1 (fn [] (io.popen run-str)))] + pid (.. "\"" (: current-app :pid) "\" ") + title (.. "\"" (: current-app :title) "\" ") + run-str (.. + "/usr/local/bin/emacsclient" + " -c -F '(quote (name . \"capture\"))'" + " -e '(activate-capture-frame " + pid title key " )'") + timer (hs.timer.delayed.new .1 (fn [] (io.popen run-str)))] (: log :i run-str) (: timer :start))) diff --git a/spacehammer.el b/spacehammer.el index b6ae90d..d4040fd 100644 --- a/spacehammer.el +++ b/spacehammer.el @@ -102,6 +102,32 @@ TITLE is a title of the window (the caller is responsible to set that right)" (spacehammer/switch-to-app systemwide-edit-previous-app-pid) (setq systemwide-edit-previous-app-pid nil)) +;;;; System-wide org capture +(defvar systemwide-capture-previous-app-pid nil + "Last app that invokes `activate-capture-frame'.") + +(defun activate-capture-frame (&optional pid title keys) + "Run ‘org-capture’ in capture frame. + +PID is a pid of the app (the caller is responsible to set that right) +TITLE is a title of the window (the caller is responsible to set that right) +KEYS is a string associated with a template (will be passed to `org-capture')" + (setq systemwide-capture-previous-app-pid pid) + (message "activating capture frame" ) + (message pid) + (message keys) + (select-frame-by-name "capture") + (set-frame-position nil 400 400) + (set-frame-size nil 1000 400 t) + (switch-to-buffer (get-buffer-create "*scratch*")) + (org-capture nil keys)) + +(defadvice org-switch-to-buffer-other-window + (after supress-window-splitting activate) + "Delete the extra window if we're in a capture frame." + (if (equal "capture" (frame-parameter nil 'name)) + (delete-other-windows))) + (provide 'spacehammer) ;;; spacehammer.el ends here From e43282524d3fee832151b2d81936912315ba13ff Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Mon, 2 Sep 2019 22:04:53 -0700 Subject: [PATCH 10/34] a few org-capture related functions I forgot to move to spacehammer.el --- spacehammer.el | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/spacehammer.el b/spacehammer.el index d4040fd..9e643fc 100644 --- a/spacehammer.el +++ b/spacehammer.el @@ -113,9 +113,6 @@ PID is a pid of the app (the caller is responsible to set that right) TITLE is a title of the window (the caller is responsible to set that right) KEYS is a string associated with a template (will be passed to `org-capture')" (setq systemwide-capture-previous-app-pid pid) - (message "activating capture frame" ) - (message pid) - (message keys) (select-frame-by-name "capture") (set-frame-position nil 400 400) (set-frame-size nil 1000 400 t) @@ -128,6 +125,25 @@ KEYS is a string associated with a template (will be passed to `org-capture')" (if (equal "capture" (frame-parameter nil 'name)) (delete-other-windows))) +(defadvice org-capture-finalize + (after delete-capture-frame activate) + "Advise capture-finalize to close the frame." + (when (and (equal "capture" (frame-parameter nil 'name)) + (not (eq this-command 'org-capture-refile))) + (spacehammer/switch-to-app systemwide-capture-previous-app-pid) + (delete-frame))) + +(defadvice org-capture-refile + (after delete-capture-frame activate) + "Advise ‘org-refile’ to close the frame." + (delete-frame)) + +(defadvice user-error + (before before-user-error activate) + "Advice" + (when (eq (buffer-name) "*Org Select*") + (spacehammer/switch-to-app systemwide-capture-previous-app-pid))) + (provide 'spacehammer) ;;; spacehammer.el ends here From 477d1f1588756e37825eaf94db491e1870749b27 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 7 Sep 2019 20:59:01 -0700 Subject: [PATCH 11/34] improves spacehammer/fix-frame --- spacehammer.el | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/spacehammer.el b/spacehammer.el index 9e643fc..5c8d83b 100644 --- a/spacehammer.el +++ b/spacehammer.el @@ -15,10 +15,18 @@ nil 0 nil "-c" (concat "hs.alert.show(\"" message "\", 1)")))) (defun spacehammer/fix-frame () - "Fix Emacs frame. Usually it's necessary when screen size changes." - (when (spacemacs/toggle-fullscreen-frame-status) - (spacemacs/toggle-fullscreen-frame-off) - (spacemacs/toggle-fullscreen-frame-on))) + "Fix Emacs frame. It may be necessary when screen size changes. + +Sometimes zoom-frm functions would leave visible margins around the frame." + (cond + ((eq (frame-parameter nil 'fullscreen) 'fullboth) + (progn + (set-frame-parameter (selected-frame) 'fullscreen 'fullheight) + (set-frame-parameter (selected-frame) 'fullscreen 'fullboth))) + ((eq (frame-parameter nil 'fullscreen) 'maximized) + (progn + (set-frame-parameter (selected-frame) 'fullscreen 'fullwidth) + (set-frame-parameter (selected-frame) 'fullscreen 'maximized))))) (defun spacehammer/move-frame-one-display (direction) "Moves current Emacs frame to another display at given DIRECTION From 4aef081be86e386abfecaf88a8de86cf3975bbfa Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 10 Oct 2019 21:14:31 -0400 Subject: [PATCH 12/34] Added reset! to lib/atom.fnl --- lib/atom.fnl | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/atom.fnl b/lib/atom.fnl index 52b985d..8fe5e82 100644 --- a/lib/atom.fnl +++ b/lib/atom.fnl @@ -38,10 +38,15 @@ (notify-watchers atom next-value prev-value) atom)) -{:atom atom - :new atom - :deref deref +(fn reset! + [atom v] + (swap! atom (fn [] v))) + +{:atom atom + :new atom + :deref deref :notify-watchers notify-watchers - :add-watch add-watch - :remove-watch remove-watch - :swap! swap!} + :add-watch add-watch + :remove-watch remove-watch + :reset! reset! + :swap! swap!} From 799aa8bdb39cde3cc45192ae57e5eca23990ac94 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 10 Oct 2019 21:20:29 -0400 Subject: [PATCH 13/34] Remove commas from s-expressions for fennel 0.3.0 --- lib/apps.fnl | 2 +- lib/functional.fnl | 25 ++++++++++++++++--------- lib/macros.fnl | 6 +++--- lib/modal.fnl | 2 +- lib/text.fnl | 2 +- windows.fnl | 4 ++-- 6 files changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index c53065c..5389736 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -21,7 +21,7 @@ (local lifecycle (require :lib.lifecycle)) -(local log (hs.logger.new "apps.fnl", "debug")) +(local log (hs.logger.new "apps.fnl" "debug")) (local actions (atom.new nil)) (var fsm nil) diff --git a/lib/functional.fnl b/lib/functional.fnl index e4d29f1..10d73d2 100644 --- a/lib/functional.fnl +++ b/lib/functional.fnl @@ -26,7 +26,7 @@ (fn has-some? [list] - (and list (> (# list) 0))) + (and list (> (length list) 0))) (fn identity [x] x) @@ -37,7 +37,7 @@ (fn last [list] - (. list (# list))) + (. list (length list))) (fn logf [...] @@ -49,19 +49,25 @@ [] nil) +(fn slice-end-idx + [end-pos list] + (if (< end-pos 0) + (+ (length list) end-pos) + end-pos)) + (fn slice-start-end [start end list] - (let [end (if (< end 0) - (+ (# list) end) - end)] + (let [end+ (if (< end 0) + (+ (length list) end) + end)] (var sliced []) - (for [i start end] + (for [i start end+] (table.insert sliced (. list i))) sliced)) (fn slice-start [start list] - (slice-start-end start (# list) list)) + (slice-start-end start (length list) list)) (fn slice [start end list] @@ -70,6 +76,7 @@ (slice-start start end) (slice-start-end start end list))) + (fn split [search str] (fu.split str search)) @@ -161,7 +168,7 @@ (fn some [f tbl] (let [filtered (filter f tbl)] - (>= (# filtered) 1))) + (>= (length filtered) 1))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -171,7 +178,7 @@ (fn eq? [l1 l2] (if (and (= (type l1) (type l2) "table") - (= (# l1) (# l2))) + (= (length l1) (length l2))) (fu.every l1 (fn [v] (contains? v l2))) (= (type l1) (type l2)) diff --git a/lib/macros.fnl b/lib/macros.fnl index 4125fa7..94ef965 100644 --- a/lib/macros.fnl +++ b/lib/macros.fnl @@ -1,8 +1,8 @@ (fn when-let [[var-name value] body1 ...] (assert body1 "expected body") - `(let [@var-name @value] - (when @var-name - @body1 @...))) + `(let [,var-name ,value] + (when ,var-name + ,body1 ,...))) {:when-let when-let} diff --git a/lib/modal.fnl b/lib/modal.fnl index ba7a838..332403d 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -279,7 +279,7 @@ (let [{:config config :history history} state history (slice 1 -1 history) - main-menu (= 0 (# history)) + main-menu (= 0 (length history)) navigate (if main-menu idle->active active->submenu)] diff --git a/lib/text.fnl b/lib/text.fnl index 7abd495..0225de0 100644 --- a/lib/text.fnl +++ b/lib/text.fnl @@ -9,7 +9,7 @@ (fn max-length [items] (reduce - (fn [max [key _]] (math.max max (# key))) + (fn [max [key _]] (math.max max (length key))) 0 items)) diff --git a/windows.fnl b/windows.fnl index 12149d9..2d54725 100644 --- a/windows.fnl +++ b/windows.fnl @@ -17,7 +17,7 @@ (when (= (type tbl) :nil) (tset self id [])) (when tbl - (let [last-el (. tbl (# tbl))] + (let [last-el (. tbl (length tbl))] (when (~= last-el (: win :frame)) (table.insert tbl (: win :frame)))))))) @@ -28,7 +28,7 @@ tbl (. self id)] (when (and win tbl) (let [el (table.remove tbl) - num-of-undos (# tbl)] + num-of-undos (length tbl)] (if el (do (: win :setFrame el) From 4629f26a256cc2a2bd0b48be7ffe50c7b3dbaa83 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Tue, 26 Nov 2019 13:08:47 -0800 Subject: [PATCH 14/34] fixes system-wide capture --- emacs.fnl | 74 ++++++++++++++++++++------------------------------ spacehammer.el | 4 +-- 2 files changed, 31 insertions(+), 47 deletions(-) diff --git a/emacs.fnl b/emacs.fnl index 89422b1..b1e4037 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -1,28 +1,21 @@ -(local log (hs.logger.new "emacs.fnl" "debug")) -(fn capture - [note?] - (let [key (if note? "\"z\"" "") +(fn capture [is-note] + (let [key (if is-note "\"z\"" "") current-app (hs.window.focusedWindow) - pid (.. "\"" (: current-app :pid) "\" ") - title (.. "\"" (: current-app :title) "\" ") - run-str (.. - "/usr/local/bin/emacsclient" - " -c -F '(quote (name . \"capture\"))'" - " -e '(activate-capture-frame " - pid title key " )'") - timer (hs.timer.delayed.new .1 (fn [] (io.popen run-str)))] - (: log :i run-str) + pid (.. "\"" (: current-app :pid) "\" ") + title (.. "\"" (: current-app :title) "\" ") + run-str (.. + "/usr/local/bin/emacsclient" + " -c -F '(quote (name . \"capture\"))'" + " -e '(spacehammer/activate-capture-frame " + pid title key " )'") + timer (hs.timer.delayed.new .1 (fn [] (io.popen run-str)))] (: timer :start))) -(fn note [] (capture true)) - ;; executes emacsclient, evaluating special function that must be present in ;; Emacs config, passing pid and title of the caller app, along with display id ;; where the screen of the caller app is residing -(fn edit-with-emacs - [] - (: log :i "Editing with emacs") +(fn edit-with-emacs [] (let [current-app (: (hs.window.focusedWindow) :application) pid (.. "\"" (: current-app :pid) "\"") title (.. "\"" (: current-app :title) "\"") @@ -33,15 +26,13 @@ " -e '(spacehammer/edit-with-emacs " pid " " title " " screen " )'")] ;; select all + copy - (: log :i run-str) (hs.eventtap.keyStroke [:cmd] :a) (hs.eventtap.keyStroke [:cmd] :c) (io.popen run-str))) ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/edit-with-emacs` in spacehammer.el -(fn edit-with-emacs-callback - [pid title screen] +(fn edit-with-emacs-callback [pid title screen] (let [emacs-app (hs.application.get :Emacs) edit-window (: emacs-app :findWindow :edit) scr (hs.screen.find (tonumber screen)) @@ -51,7 +42,7 @@ (: windows :center-window-frame)))) ;; global keybinging to invoke edit-with-emacs feature -(local edit-with-emacs-key (hs.hotkey.bind [:cmd :ctrl] :o edit-with-emacs)) +(local edit-with-emacs-key (hs.hotkey.new [:cmd :ctrl] :o nil edit-with-emacs)) (fn run-emacs-fn ;; executes given elisp function via emacsclient, if args table present passes @@ -62,7 +53,6 @@ " -e \"(funcall '" elisp-fn (if args-lst args-lst "") ")\"")] - (: log :i run-str) (io.popen run-str))) (fn full-screen @@ -112,13 +102,23 @@ (: fsm :toIdle) (full-screen)))) +;; adds Emacs modal state to the FSM instance +(fn add-state [modal] + (modal.add-state + :emacs + {:from :* + :init (fn [self fsm] + (set self.hotkeyModal (hs.hotkey.modal.new)) + (modal.display-modal-text "c \tcapture\nz\tnote\nf\tfullscreen\nv\tsplit") + (bind self.hotkeyModal fsm) + (: self.hotkeyModal :enter))})) + ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/switch-to-app` in spacehammer.el (fn switch-to-app [pid] (let [app (hs.application.applicationForPID pid)] (when app (: app :activate)))) - ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/finish-edit-with-emacs` in spacehammer.el (fn switch-to-app-and-paste-from-clipboard [pid] @@ -127,25 +127,12 @@ (: app :activate) (: app :selectMenuItem [:Edit :Paste])))) - (fn disable-edit-with-emacs [] (: edit-with-emacs-key :disable)) (fn enable-edit-with-emacs [] (: edit-with-emacs-key :enable)) -(fn maximize - [] - (hs.timer.doAfter - 1.5 - (fn [] - (let [app (hs.application.find :Emacs) - windows (require :windows) - modal (require :modal)] - (when app - (: app :activate) - (windows.maximize-window-frame)))))) - (fn add-app-specific [] (let [keybindings (require :keybindings)] (keybindings.add-app-specific @@ -165,14 +152,11 @@ (: app :activate) (windows.maximize-window-frame (: modal :machine)))))))}))) -{:edit-with-emacs edit-with-emacs +{:enable-edit-with-emacs enable-edit-with-emacs + :disable-edit-with-emacs disable-edit-with-emacs + :add-state add-state + :edit-with-emacs edit-with-emacs :switchToApp switch-to-app :switchToAppAndPasteFromClipboard switch-to-app-and-paste-from-clipboard :editWithEmacsCallback edit-with-emacs-callback - :enable-edit-with-emacs enable-edit-with-emacs - :disable-edit-with-emacs disable-edit-with-emacs - :capture capture - :maximize maximize - :note note - :full-screen full-screen - :vertical-split-with-emacs vertical-split-with-emacs} + :add-app-specific add-app-specific} diff --git a/spacehammer.el b/spacehammer.el index 5c8d83b..2a1c1db 100644 --- a/spacehammer.el +++ b/spacehammer.el @@ -112,9 +112,9 @@ TITLE is a title of the window (the caller is responsible to set that right)" ;;;; System-wide org capture (defvar systemwide-capture-previous-app-pid nil - "Last app that invokes `activate-capture-frame'.") + "Last app that invokes `spacehammer/activate-capture-frame'.") -(defun activate-capture-frame (&optional pid title keys) +(defun spacehammer/activate-capture-frame (&optional pid title keys) "Run ‘org-capture’ in capture frame. PID is a pid of the app (the caller is responsible to set that right) From d8e5d596f398d1379a48ce80cf1246cb0dc3c2ab Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Wed, 27 Nov 2019 21:48:42 -0800 Subject: [PATCH 15/34] fixing unquote for comparability with fennel 0.3.0 --- lib/apps.fnl | 2 +- lib/macros.fnl | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/apps.fnl b/lib/apps.fnl index c53065c..5389736 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -21,7 +21,7 @@ (local lifecycle (require :lib.lifecycle)) -(local log (hs.logger.new "apps.fnl", "debug")) +(local log (hs.logger.new "apps.fnl" "debug")) (local actions (atom.new nil)) (var fsm nil) diff --git a/lib/macros.fnl b/lib/macros.fnl index 4125fa7..bb1e926 100644 --- a/lib/macros.fnl +++ b/lib/macros.fnl @@ -1,8 +1,7 @@ (fn when-let [[var-name value] body1 ...] (assert body1 "expected body") - `(let [@var-name @value] - (when @var-name - @body1 @...))) + `(let [,var-name ,value] + (when ,var-name ,body1 ,...))) {:when-let when-let} From dcb309cb8a2780a28765180320eafdec237c3df8 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 21 Dec 2019 00:34:00 -0800 Subject: [PATCH 16/34] emacs: update fix-frame function Instead of toggling fullscreen off and on, calculating the exact pixel value and adjusting the frame accordingly makes it less jittery. related issue: https://github.com/syl20bnr/spacemacs/issues/13105 --- spacehammer.el | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/spacehammer.el b/spacehammer.el index 2a1c1db..5c0b840 100644 --- a/spacehammer.el +++ b/spacehammer.el @@ -18,15 +18,23 @@ "Fix Emacs frame. It may be necessary when screen size changes. Sometimes zoom-frm functions would leave visible margins around the frame." - (cond - ((eq (frame-parameter nil 'fullscreen) 'fullboth) - (progn - (set-frame-parameter (selected-frame) 'fullscreen 'fullheight) - (set-frame-parameter (selected-frame) 'fullscreen 'fullboth))) - ((eq (frame-parameter nil 'fullscreen) 'maximized) - (progn - (set-frame-parameter (selected-frame) 'fullscreen 'fullwidth) - (set-frame-parameter (selected-frame) 'fullscreen 'maximized))))) + (let* ((geom (frame-monitor-attribute 'geometry)) + (height (- (first (last geom)) 2)) + (width (nth 2 geom)) + (fs-p (frame-parameter nil 'fullscreen)) + (frame (selected-frame)) + (x (first geom)) + (y (second geom))) + (when (member fs-p '(fullboth maximized)) + (set-frame-position frame x y) + (set-frame-height frame height nil t) + (set-frame-width frame width nil t)) + (when (frame-parameter nil 'full-width) + (set-frame-width frame width nil t) + (set-frame-parameter nil 'full-width nil)) + (when (frame-parameter nil 'full-height) + (set-frame-height frame height nil t) + (set-frame-parameter nil 'full-height nil)))) (defun spacehammer/move-frame-one-display (direction) "Moves current Emacs frame to another display at given DIRECTION From 691053422a6a0c2aedc80ec4236f93bd2dcd643a Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 21 Dec 2019 01:50:02 -0800 Subject: [PATCH 17/34] improves console toggling with ctr+cmd+` --- core.fnl | 13 +++++-------- lib/macros.fnl | 12 +++++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/core.fnl b/core.fnl index 2c6b921..555442f 100644 --- a/core.fnl +++ b/core.fnl @@ -85,14 +85,11 @@ (hs.hotkey.bind [:ctrl :cmd] "`" nil (fn [] - (when-let [console (hs.console.hswindow)] - (if (= console (hs.window.focusedWindow)) - (-> console (: :application) (: :hide)) - (-> console (: :raise) (: :focus)))))) - -;; disable annoying Cmd+M for minimizing windows -;; (hs.hotkey.bind [:cmd] :m nil (fn [] nil)) - + (if-let + [console (hs.console.hswindow)] + (when (= console (hs.console.hswindow)) + (hs.closeConsole)) + (hs.openConsole)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Load private init.fnl file (if it exists) diff --git a/lib/macros.fnl b/lib/macros.fnl index 2ad8fb3..3c2d01e 100644 --- a/lib/macros.fnl +++ b/lib/macros.fnl @@ -4,4 +4,14 @@ `(let [,var-name ,value] (when ,var-name ,body1 ,...))) -{:when-let when-let} + +(fn if-let + [[var-name value] body1 ...] + (assert body1 "expected body") + `(let [,var-name ,value] + (if ,var-name + ,body1 + ,...))) + +{:when-let when-let + :if-let if-let} From 9db6be81470541ded1c9610fbaf49e1a2692a2c3 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 21 Dec 2019 02:10:50 -0800 Subject: [PATCH 18/34] added a few docstrings --- lib/functional.fnl | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/functional.fnl b/lib/functional.fnl index 10d73d2..a9796f1 100644 --- a/lib/functional.fnl +++ b/lib/functional.fnl @@ -6,15 +6,18 @@ (fn call-when [f] + "Execute function if it is not nil." (when (and f (= (type f) :function)) (f))) (fn contains? [x xs] + "Returns true if key is present in the given collection, otherwise returns false." (and xs (fu.contains xs x))) (fn find [f tbl] + "Execute a function across a table and return the first element where that function returns true." (fu.find tbl f)) (fn get @@ -26,7 +29,7 @@ (fn has-some? [list] - (and list (> (length list) 0))) + (and list (< 0 (length list)))) (fn identity [x] x) @@ -76,10 +79,10 @@ (slice-start start end) (slice-start-end start end list))) - (fn split - [search str] - (fu.split str search)) + [separator str] + "Using specified separator, convert string to an array of strings." + (fu.split str separator)) (fn tap [f x ...] @@ -168,7 +171,7 @@ (fn some [f tbl] (let [filtered (filter f tbl)] - (>= (length filtered) 1))) + (<= 1 (length filtered)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From e1998300f2aa8a2d074dbe00b321650e37ac3848 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 21 Dec 2019 19:12:07 -0800 Subject: [PATCH 19/34] fixes missing emacs exports --- emacs.fnl | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/emacs.fnl b/emacs.fnl index b1e4037..3208ebe 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -152,11 +152,15 @@ (: app :activate) (windows.maximize-window-frame (: modal :machine)))))))}))) -{:enable-edit-with-emacs enable-edit-with-emacs - :disable-edit-with-emacs disable-edit-with-emacs - :add-state add-state - :edit-with-emacs edit-with-emacs - :switchToApp switch-to-app - :switchToAppAndPasteFromClipboard switch-to-app-and-paste-from-clipboard - :editWithEmacsCallback edit-with-emacs-callback - :add-app-specific add-app-specific} +{:add-app-specific add-app-specific + :add-state add-state + :capture capture + :disable-edit-with-emacs disable-edit-with-emacs + :edit-with-emacs edit-with-emacs + :editWithEmacsCallback edit-with-emacs-callback + :enable-edit-with-emacs enable-edit-with-emacs + :full-screen full-screen + :note (fn [] (capture true)) + :switchToApp switch-to-app + :switchToAppAndPasteFromClipboard switch-to-app-and-paste-from-clipboard + :vertical-split-with-emacs vertical-split-with-emacs} From 772ef48c03c37675593de1f4464b2d6503e62ab3 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Thu, 2 Jan 2020 11:52:22 -0800 Subject: [PATCH 20/34] fixes slack local keys require was accidentally removed --- config.fnl | 1 + slack.fnl | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/config.fnl b/config.fnl index d1b4e67..1189d43 100644 --- a/config.fnl +++ b/config.fnl @@ -1,5 +1,6 @@ (local windows (require :windows)) (local emacs (require :emacs)) +(local slack (require :slack)) (local vim (require :vim)) (local {:concat concat diff --git a/slack.fnl b/slack.fnl index 3cac595..c08c840 100644 --- a/slack.fnl +++ b/slack.fnl @@ -82,8 +82,6 @@ [] (hs.eventtap.keyStroke nil :down)) - - {:add-reaction add-reaction :down down :next-day next-day From e24dc0fa44cf9e59cadf5519efa0731f609c1bf5 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 12 Jan 2020 18:03:08 -0500 Subject: [PATCH 21/34] Fixes local app bindings\menus not working randomly agzam/spacehammer#31 - Additional logging showed the hs.application.watcher was not responding at random times. - Further research hinted at Hammerspoon/hammerspoon#681 - Research indicates hs.application.watcher instance was garbage collected - Attempt to store resources created by modules in table to retain references. --- core.fnl | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/core.fnl b/core.fnl index 555442f..05cc27e 100644 --- a/core.fnl +++ b/core.fnl @@ -5,6 +5,8 @@ (local {:contains? contains? :for-each for-each :map map + :merge merge + :reduce reduce :split split :some some} (require :lib.functional)) (require-macros :lib.macros) @@ -112,8 +114,11 @@ :lib.modal :lib.apps]) -(->> modules - (map require) - (for-each - (fn [module] - (module.init config)))) +;; Create a global reference so services like hs.application.watcher +;; do not get garbage collected. +(global resources + (->> modules + (map (fn [path] + (let [module (require path)] + {path (module.init config)}))) + (reduce #(merge $1 $2) {}))) From 06c11e70c21c4489eb16bc6cb04f2a13b48b99ea Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 14 Jan 2020 10:49:37 -0500 Subject: [PATCH 22/34] Change configdir from ~/.hammerspoon/private to ~/.spacehammer - Generate ~/.spacehammer/config.fnl if one does not exist - Discussed in https://github.com/agzam/spacehammer/issues/30 Updated README.md to reference new ~/.spacehammer config dir Created routine to create a default custom config.fnl by default Updated docs to reflect automatic custom config generation --- README.ORG | 31 ++++++++++++++----------------- config.fnl | 12 +++++------- core.fnl | 49 ++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/README.ORG b/README.ORG index 1336719..fe2abaa 100644 --- a/README.ORG +++ b/README.ORG @@ -97,21 +97,19 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon ** Customizing *** Update menus, menu items, bindings, and app specific features - All menu, app, and key bindings are defined in config.fnl. - You may edit this file directly but may run into conflicts with upstream changes. - Alternatively you can create a =~/.hammerspoon/private/config.fnl= that can by symlinked and tracked in your dotfiles. - The =~/.hammerspoon/private= directory is not source tracked so your customizations will be safe from upstream updates. + All menu, app, and key bindings are defined in =~/.spacehammer/config.fnl=. + That is your custom config and will be safe from any upstream changes to the default config.fnl. **** Modal Menu Items Menu items are listed when you press =cmd+space= and can be nested. - Items map a key binding to an action, either a function or ="module:function-name"= string. - + Items map a key binding to an action, either a function or ="module:function-name"= string. + Menu items may either define an action or a table list of items. - + For menu items that should be repeated, add =repeatable: true= to the item table. - The repeatable flag keeps the menu option after the action has been triggered. + The repeatable flag keeps the menu option after the action has been triggered. Repeating a menu item is ideal for actions like window layouts where you may wish to move the window from the left third to the right third. - + #+BEGIN_SRC fennel (local launch-alfred {:title "Alfred" :key :SPACE @@ -133,18 +131,18 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon window-inc submenu]}) #+END_SRC - + ***** Lifecycle methods Menu items may also define =:enter= and =:exit= functions or action strings. The parent menu item will call the =enter= function when it is opened and =exit= when it is closed. This may be used to manage more complex, or dynamic menus. **** Global keys Global keys are used to set up universal hot-keys for the actions you specify. Unlike menu items they do not require a title attribute. Additionally you may specify =repeat: true= to repeat the action while the key is held down. - + If you place =:hyper= as a mod, it will use a hyper mode that can be configured by the =hyper= config attribute. This can be used to help create bindings that wont interfere with other apps. For instance you may make your hyper trigger the virtual =:F18= and use a program like [[https://github.com/tekezo/Karabiner-Elements][karabiner-elements]] to map caps-lock to =F18=. - + #+BEGIN_SRC fennel (local config {:hyper {:mods [:cmd :ctrl :alt :shift]} :keys [{:mods [:cmd] @@ -161,12 +159,12 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon **** App specific customizations Configure separate menu options and key bindings while specified apps are active. Additionally, several lifecycle functions or action strings may be provided for each app. - + - `:activate` When an application receives keyboard focus - `:deactivate` When an application loses keyboard focus - `:launch` When an application is launched - `:close` When an application is terminated - + #+BEGIN_SRC fennel (local emacs-config {:key "Emacs" @@ -179,6 +177,5 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon (local config {:apps [emacs-config]}) #+END_SRC *** Replacing spacehammer behavior - The =~/.hammerspoon/private= directory is added to the module search paths. - If you wish to change the behavior of a feature, such as vim mode, you can create =~/.hammerspoon/private/vim.fnl= to override the default implementation. - + The =~/.spacehammer= directory is added to the module search paths. + If you wish to change the behavior of a feature, such as vim mode, you can create =~/.spacehammer/vim.fnl= to override the default implementation. diff --git a/config.fnl b/config.fnl index 1189d43..4eb82b1 100644 --- a/config.fnl +++ b/config.fnl @@ -7,13 +7,11 @@ :logf logf} (require :lib.functional)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Default Config -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; -;; - It is not recommended to edit this file. -;; - Changes may conflict with upstream updates. -;; - Create a ~/.hammerspoon/private/config.fnl file instead. -;; +;; WARNING +;; Make sure you are customizing ~/.spacehammer/config.fnl and not +;; ~/.hammerspoon/config.fnl +;; Otherwise you will lose your customizations on upstream changes. +;; A copy of this file should already exist in your ~/.spacehammer directory. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/core.fnl b/core.fnl index 05cc27e..3e4f672 100644 --- a/core.fnl +++ b/core.fnl @@ -1,4 +1,3 @@ -(hs.console.clearConsole) (hs.ipc.cliInstall) ; ensure CLI installed (local fennel (require :fennel)) @@ -11,9 +10,12 @@ :some some} (require :lib.functional)) (require-macros :lib.macros) -;; Make private folder override repo files -(local private (.. hs.configdir "/private")) -(tset fennel :path (.. private "/?.fnl;" fennel.path)) +;; Make ~/.spacehammer folder override repo files +(local homedir (os.getenv "HOME")) +(local customdir (.. homedir "/.spacehammer")) +(tset fennel :path (.. customdir "/?.fnl;" fennel.path)) + +(local log (hs.logger.new "\tcore.fnl\t" "debug")) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; defaults @@ -40,6 +42,33 @@ (io.close file)) (~= file nil))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; create custom config file if it doesn't exist +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fn copy-file + [source dest] + " + Copies the contents of a source file to a destination file. + Takes a source file path and a destination file path. + Returns nil + " + (let [default-config (io.open source "r") + custom-config (io.open dest "a")] + (each [line _ (: default-config :lines)] + (: custom-config :write (.. line "\n"))) + (: custom-config :close) + (: default-config :close))) + +;; If ~/.spacehammer/config.fnl does not exist +;; - Create ~/.spacehammer dir +;; - Copy default ~/.hammerspoon/config.fnl to ~/.spacehammer/config.fnl +(when (not (file-exists? (.. customdir "/config.fnl"))) + (log.d "Copying ~/.hammerspoon/config.fnl to ~/.spacehammer/config.fnl") + (hs.fs.mkdir customdir) + (copy-file (.. homedir "/.hammerspoon/config.fnl") + (.. customdir "/config.fnl"))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; auto reload config @@ -63,6 +92,7 @@ (fn config-reloader [files] (when (some source-updated? files) + (hs.console.clearConsole) (hs.reload))) (fn watch-files @@ -74,8 +104,8 @@ (global config-files-watcher (watch-files hs.configdir)) -(when (file-exists? (.. private "/config.fnl")) - (global custom-files-watcher (watch-files private))) +(when (file-exists? (.. customdir "/config.fnl")) + (global custom-files-watcher (watch-files customdir))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -94,11 +124,12 @@ (hs.openConsole)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Load private init.fnl file (if it exists) +;; Load custom init.fnl file (if it exists) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(when (file-exists? (.. private "/init.fnl")) - (require :private)) +(let [custom-init-file (.. customdir "/init.fnl")] + (when (file-exists? custom-init-file) + (fennel.dofile custom-init-file))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 6d165c38748637b8a09535317295d89791e1494b Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 24 Aug 2019 19:59:06 -0400 Subject: [PATCH 23/34] Documented lib and core features - Updated core docs - Moved hs console keybinding into config.fnl Documented lib/apps.fnl Documented lib/atom.fnl API and behaviors Documented lib/bind.fnl Documented lib/hyper, lib/lifecycle, lib/macros, lib/text, and lib/utils.fnl Documented apps.fnl, chrome.fnl, and multimedia.fnl Documented slack.fnl, vim.fnl, and windows.fnl Added maximize function back to emacs.fnl and removed old state machine refs Documented config.fnl activator Updated core docs, moved hs console binding to default config --- apps.fnl | 15 +++- chrome.fnl | 13 +++ config.fnl | 31 +++++++- core.fnl | 48 ++++++++++- emacs.fnl | 65 ++++----------- lib/apps.fnl | 199 +++++++++++++++++++++++++++++++++++++++++++--- lib/atom.fnl | 120 ++++++++++++++++++++++++++++ lib/bind.fnl | 68 +++++++++++++--- lib/hyper.fnl | 45 ++++++++++- lib/lifecycle.fnl | 25 ++++++ lib/macros.fnl | 23 ++++++ lib/modal.fnl | 183 +++++++++++++++++++++++++++++++++++++++++- lib/text.fnl | 29 ++++++- lib/utils.fnl | 9 +++ lib/utils.lua | 114 -------------------------- multimedia.fnl | 21 +++++ slack.fnl | 11 ++- vim.fnl | 24 ++++++ windows.fnl | 80 +++++++++++++++++-- 19 files changed, 920 insertions(+), 203 deletions(-) create mode 100644 lib/utils.fnl delete mode 100644 lib/utils.lua diff --git a/apps.fnl b/apps.fnl index fc1a62b..cf1fa01 100644 --- a/apps.fnl +++ b/apps.fnl @@ -1,4 +1,4 @@ -(local utils (require :lib.utils)) +(local {:global-filter global-filter} (require :lib.utils)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; App switcher @@ -6,7 +6,7 @@ (local switcher (hs.window.switcher.new - (utils.globalFilter) + (global-filter) {:textSize 12 :showTitles false :showThumbnails false @@ -16,10 +16,21 @@ (fn prev-app [] + " + Open the fancy hammerspoon window switcher and move the cursor to the previous + app. + Runs side-effects + Returns nil + " (: switcher :previous)) (fn next-app [] + " + Open the fancy hammerspoon window switcher and move the cursor to next app. + Runs side-effects + Returns nil + " (: switcher :next)) diff --git a/chrome.fnl b/chrome.fnl index 18f0b15..1f4b92e 100644 --- a/chrome.fnl +++ b/chrome.fnl @@ -3,15 +3,28 @@ ;; setting conflicting Cmd+L (jump to address bar) keybinding to Cmd+Shift+L (fn open-location [] + " + Activate the Chrome > File > Open Location... action which moves focus to the + address\search bar. + Returns nil + " (when-let [app (: (hs.window.focusedWindow) :application)] (: app :selectMenuItem ["File" "Open Location…"]))) (fn prev-tab [] + " + Send the key stroke cmd+shift+[ to move to the previous tab. + This shortcut is shared by a lot of apps in addition to Chrome!. + " (hs.eventtap.keyStroke [:cmd :shift] "[")) (fn next-tab [] + " + Send the key stroke cmd+shift+] to move to the next tab. + This shortcut is shared by a lot of apps in addition to Chrome!. + " (hs.eventtap.keyStroke [:cmd :shift] "]")) {:open-location open-location diff --git a/config.fnl b/config.fnl index 1189d43..230135c 100644 --- a/config.fnl +++ b/config.fnl @@ -1,3 +1,4 @@ +(require-macros :lib.macros) (local windows (require :windows)) (local emacs (require :emacs)) (local slack (require :slack)) @@ -74,16 +75,39 @@ (fn activator [app-name] + " + A higher order function to activate a target app. It's useful for quickly + binding a modal menu action or hotkey action to launch or focus on an app. + Takes a string application name + Returns a function to activate that app. + + Example: + (local launch-emacs (activator \"Emacs\")) + (launch-emacs) + " (fn activate [] (windows.activate-app app-name))) +(fn toggle-console + [] + " + A simple action function to toggle the hammer spoon console. + Change the keybinding in the common keys section of this config file. + " + (if-let [console (hs.console.hswindow)] + (hs.closeConsole) + (hs.openConsole))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; General ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; If you would like to customize this we recommend copying this file to +;; ~/.hammerspoon/private/config.fnl. That will be used in place of the default +;; and will not be overwritten by upstream changes when spacehammer is updated. (local music-app - "Spotify") + "Google Play Music Desktop Player") (local return {:key :space @@ -324,7 +348,10 @@ :action "apps:next-app"} {:mods [:cmd] :key :p - :action "apps:prev-app"}]) + :action "apps:prev-app"}] + {:mods [:cmd :ctrl] + :key "`" + :action toggle-console}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/core.fnl b/core.fnl index 05cc27e..77527fe 100644 --- a/core.fnl +++ b/core.fnl @@ -25,6 +25,12 @@ (set hs.hints.fontSize 30) (set hs.window.animationDuration 0.2) +" +alert :: str, { style }, seconds -> nil +Shortcut for showing an alert on the primary screen for a specified duration +Takes a message string, a style table, and the number of seconds to show alert +Returns nil. This function causes side-effects. +" (global alert (fn [str style seconds] (hs.alert.show str @@ -35,6 +41,11 @@ (fn file-exists? [filepath] + " + Determine if a file exists and is readable. + Takes a file path string + Returns true if file is readable + " (let [file (io.open filepath "r")] (when file (io.close file)) @@ -47,33 +58,63 @@ (fn source-filename? [file] + " + Determine if a file is not an emacs backup file which starts with \".#\" + Takes a file path string + Returns true if it's a source file and not an emacs backup file. + " (not (string.match file ".#"))) (fn source-extension? [file] + " + Determine if a file is a .fnl or .lua file + Takes a file string + Returns true if file extension ends in .fnl or .lua + " (let [ext (split "%p" file)] (or (contains? "fnl" ext) (contains? "lua" ext)))) (fn source-updated? [file] + " + Determine if a file is a valid source file that we can load + Takes a file string path + Returns true if file is not an emacs backup and is a .fnl or .lua type. + " (and (source-filename? file) (source-extension? file))) (fn config-reloader [files] + " + If the list of files contains some hammerspoon or spacehammer source files: + reload hammerspoon + Takes a list of files from our config file watcher. + Performs side effect of reloading hammerspoon. + Returns nil + " (when (some source-updated? files) (hs.reload))) (fn watch-files [dir] + " + Watches hammerspoon or spacehammer source files. When a file updates we reload + hammerspoon. + Takes a directory to watch. + Returns a function to stop the watcher. + " (let [watcher (hs.pathwatcher.new dir config-reloader)] (: watcher :start) (fn [] (: watcher :stop)))) +;; Create a global config-files-watcher. Calling it stops the default watcher (global config-files-watcher (watch-files hs.configdir)) +;; Create a config-files-watcher for the private dir (when (file-exists? (.. private "/config.fnl")) (global custom-files-watcher (watch-files private))) @@ -102,11 +143,16 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Initialize Modals & Apps +;; Initialize core modules +;; - Requires each module +;; - Calls module.init and provides config.fnl table +;; - Stores global reference to all initialized resources to prevent garbage +;; collection. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (local config (require :config)) +;; Initialize our modules that depend on config (local modules [:lib.hyper :vim :windows diff --git a/emacs.fnl b/emacs.fnl index 3208ebe..13b8139 100644 --- a/emacs.fnl +++ b/emacs.fnl @@ -1,4 +1,3 @@ - (fn capture [is-note] (let [key (if is-note "\"z\"" "") current-app (hs.window.focusedWindow) @@ -87,32 +86,6 @@ (hs.application.launchOrFocus :Emacs) (windows.rect rect-left))))))) -(fn bind [hotkeyModal fsm] - (: hotkeyModal :bind nil :c (fn [] - (: fsm :toIdle) - (capture))) - (: hotkeyModal :bind nil :z (fn [] - (: fsm :toIdle) - ;; note on currently clocked in - (capture true))) - (: hotkeyModal :bind nil :v (fn [] - (: fsm :toIdle) - (vertical-split-with-emacs))) - (: hotkeyModal :bind nil :f (fn [] - (: fsm :toIdle) - (full-screen)))) - -;; adds Emacs modal state to the FSM instance -(fn add-state [modal] - (modal.add-state - :emacs - {:from :* - :init (fn [self fsm] - (set self.hotkeyModal (hs.hotkey.modal.new)) - (modal.display-modal-text "c \tcapture\nz\tnote\nf\tfullscreen\nv\tsplit") - (bind self.hotkeyModal fsm) - (: self.hotkeyModal :enter))})) - ;; Don't remove! - this is callable from Emacs ;; See: `spacehammer/switch-to-app` in spacehammer.el (fn switch-to-app [pid] @@ -133,33 +106,29 @@ (fn enable-edit-with-emacs [] (: edit-with-emacs-key :enable)) -(fn add-app-specific [] - (let [keybindings (require :keybindings)] - (keybindings.add-app-specific - :Emacs - {:activated - (fn [] - (keybindings.disable-simple-vi-mode) - (disable-edit-with-emacs)) - :launched - (fn [] - (hs.timer.doAfter 1.5 - (fn [] - (let [app (hs.application.find :Emacs) - windows (require :windows) - modal (require :modal)] - (when app - (: app :activate) - (windows.maximize-window-frame (: modal :machine)))))))}))) +(fn maximize + [] + " + Maximizes emacs window after 1.5 seconds + Example in config.fnl to call this function after emacs has been launched. + " + (hs.timer.doAfter + 1.5 + (fn [] + (let [app (hs.application.find :Emacs) + windows (require :windows) + modal (require :modal)] + (when app + (: app :activate) + (windows.maximize-window-frame)))))) -{:add-app-specific add-app-specific - :add-state add-state - :capture capture +{:capture capture :disable-edit-with-emacs disable-edit-with-emacs :edit-with-emacs edit-with-emacs :editWithEmacsCallback edit-with-emacs-callback :enable-edit-with-emacs enable-edit-with-emacs :full-screen full-screen + :maximize maximize :note (fn [] (capture true)) :switchToApp switch-to-app :switchToAppAndPasteFromClipboard switch-to-app-and-paste-from-clipboard diff --git a/lib/apps.fnl b/lib/apps.fnl index 5389736..28146d3 100644 --- a/lib/apps.fnl +++ b/lib/apps.fnl @@ -1,3 +1,12 @@ +" +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.statemachine)) (local os (require :os)) @@ -24,6 +33,8 @@ (local log (hs.logger.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) @@ -33,6 +44,12 @@ (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)))) @@ -40,6 +57,13 @@ (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]))) @@ -49,18 +73,44 @@ (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.dispatch :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.dispatch :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.dispatch :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.dispatch :close-app app-name)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -69,6 +119,11 @@ (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)) @@ -78,6 +133,13 @@ (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))) @@ -86,8 +148,20 @@ ;; State Transitions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(fn general->enter +(fn ->enter [state 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 @@ -103,23 +177,43 @@ (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 + 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)] - (if next-app - (do - (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}) - nil))) + (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->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] @@ -135,6 +229,14 @@ (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) @@ -142,6 +244,14 @@ (fn ->close [state 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 + 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.close-app app-menu) @@ -152,8 +262,23 @@ ;; 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. + +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 general->enter + {:general-app {:enter-app ->enter :leave-app noop :launch-app ->launch :close-app ->close} @@ -167,6 +292,9 @@ ;; 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 @@ -179,6 +307,16 @@ (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)] (if (= event-type :activated) (enter app-name) @@ -191,6 +329,10 @@ (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) @@ -198,6 +340,10 @@ (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 @@ -206,6 +352,15 @@ (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] @@ -218,12 +373,26 @@ (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 @@ -237,6 +406,12 @@ (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-state {:apps config.apps :app nil diff --git a/lib/atom.fnl b/lib/atom.fnl index 8fe5e82..8950a5b 100644 --- a/lib/atom.fnl +++ b/lib/atom.fnl @@ -1,10 +1,56 @@ +" +Atoms are the functional-programming answer to a variable except better +because you can subscribe to changes. + +Mechanically, an atom is a table with a current state property and a +list of watchers. + +API is provided to calculate the next value of an atom's state based on +previous value or replacing it. + +API is also provided to add watchers which takes a function to receive the +current and next value. + +API is also provided to get the value of an atom. This is called dereferencing. + +Example: +(local x (atom 5)) +(print (hs.inspect x)) +;; => { +;; :state 5 +;; :watchers {}} +(print (deref x)) +;; => 5 +;; (swap! x #(+ $1 1)) +;; (print (deref x)) +;; => 6 +;; (add-watch x :my-watcher #(print \"new:\" $1 \" old: \" $2)) +;; (reset! x 7) +;; => new: 7 old: 6 +;; (print (deref x)) +;; => 7 +;; (remove-watch x :my-watcher) +" (fn atom [initial] + " + Create an atom instance + Takes an initial value + Returns atom table instance + + Example: + (local x (atom 5)) + " {:state initial :watchers {}}) (fn copy [tbl] + " + Copies a table into a new table + Allows us to treat tables as immutable + Returns new table copy + " (if (~= (type tbl) :table) tbl (let [copy-tbl (setmetatable {} (getmetatable tbl))] @@ -14,24 +60,86 @@ (fn deref [atom] + " + Dereferences the atom instance to return the current value + Takes an atom instance + Returns the current state value of that atom. + + Example: + (local x (atom 5)) + (print (deref x)) ;; => 5 + + " (. atom :state)) (fn notify-watchers [atom next-value prev-value] + " + When updating an atom, call each watcher with the next and previous value. + Takes an atom instance, the next state value and the previous state value + Performs side-effects to call watchers + Returns nil. + " (let [watchers (. atom :watchers)] (each [_ f (pairs watchers)] (f next-value prev-value)))) (fn add-watch [atom key f] + " + Adds a watcher function by a given key to an atom instance. Allows us to + subscribe to an atom for changes. + Takes an atom instance, a key string, and a function that takes a next and + previous value. + Performs a side-effect to add a watcher for the given key. Replace previous + watcher on given key. + Returns nil + + Example: + (local x (atom 5)) + (add-watch x :custom-watcher #(print $1 \" \" $2)) + (swap! x - 1) + ;; => 4 5 + " (tset atom :watchers key f)) (fn remove-watch [atom key] + " + Removes a watcher function by a given key + Takes an atom instance and key to target a specific watcher. + Performs a side-effect of changing an atom + Returns nil + + Example: + (local x (atom 5)) + (add-watch x :custom-watcher #(print $1 \" \" $2)) + (swap! x - 1) + ;; => 4 5 + (remove-watxh x :custom-watcher) + (swap! x - 1) + ;; => x (nothing will be printed) + (deref x) + ;; => 4 + " (table.remove (. atom :watchers) key)) (fn swap! [atom f ...] + " + API to update an atom's state by performing a calculation against its current + state value. + Takes an atom instance and a function that takes the current value of the atom + plus additional args and returns the new value. + Performs a side-effect to update atom's state + Returns the atom instance + + Example: + (def x (atom 1)) + (swap! x + 1) + (deref x) + ;; => 2 + " (let [prev-value (deref atom) next-value (f (copy prev-value) (table.unpack [...]))] (set atom.state next-value) @@ -40,6 +148,18 @@ (fn reset! [atom v] + " + API to replace an atom's state value with a new value. + Takes an atom instance and the new value + Returns the updated atom instance + + Example: + (local x (atom 1)) + (reset! x 3) + ;; => x + (deref x) + ;; => 3 + " (swap! atom (fn [] v))) {:atom atom diff --git a/lib/bind.fnl b/lib/bind.fnl index 19c823d..34a7c55 100644 --- a/lib/bind.fnl +++ b/lib/bind.fnl @@ -1,5 +1,6 @@ (local hyper (require :lib.hyper)) (local {:contains? contains? + :map map :split split} (require :lib.functional)) @@ -7,6 +8,13 @@ (fn do-action [action] + " + Resolves an action string to a function in a module then runs that function. + Takes an action string like \"lib.bind:do-action\" + Performs side-effects. + Returns the return value of the target function or nil if function could + not be resolved. + " (let [[file fn-name] (split ":" action) module (require file)] (if (. module fn-name) @@ -18,12 +26,28 @@ (fn create-action-fn [action] + " + Takes an action string + Returns function to resolve and execute action. + + Example: + (hs.timer.doAfter 1 (create-action-fn \"messages:greeting\")) + ; Waits 1 second + ; Looks for a function called greeting in messages.fnl + " (fn [] (do-action action))) (fn action->fn [action] + " + Normalize an action like say from config.fnl into a function + Takes an action either a string like \"lib.bind:action->fn\" or an actual + function instance. + Returns a function to perform that action or logs an error and returns + an always true function if a function could not be found. + " (match (type action) :function action :string (create-action-fn action) @@ -35,6 +59,12 @@ (fn bind-keys [items] + " + Binds keys defined in config.fnl to action functions. + Takes a list of bindings from a config.fnl menu + Performs side-effect of binding hotkeys to action functions. + Returns a function to remove bindings. + " (let [modal (hs.hotkey.modal.new [] nil)] (each [_ item (ipairs items)] (let [{:key key @@ -55,24 +85,42 @@ (fn bind-global-keys [items] - (each [_ item (ipairs items)] - (let [{:key key} item - mods (or item.mods []) - action-fn (action->fn item.action)] - (if (contains? :hyper mods) - (hyper.bind key action-fn) - (let [binding (hs.hotkey.bind mods key action-fn)] - (fn unbind - [] - (: binding :delete))))))) + " + Binds keys to actions globally like pressing cmd + space to open modal menu + Takes a list of bindings from config.fnl + Performs side-effect of creating the key binding to a function. + Returns a function to unbding keys. + " + (map + (fn [item] + (let [{:key key} item + mods (or item.mods []) + action-fn (action->fn item.action)] + (if (contains? :hyper mods) + (hyper.bind key action-fn) + (let [binding (hs.hotkey.bind mods key action-fn)] + (fn unbind + [] + (: binding :delete)))))) + items)) (fn unbind-global-keys [bindings] + " + Takes a list of functions to remove a binding created by bind-global-keys + Performs a side effect to remove binding. + Returns nil + " (each [_ unbind (ipairs bindings)] (unbind))) (fn init [config] + " + Initializes our key bindings by binding the global keys + Creates a list of unbind functions for global keys + Returns a cleanup function to unbind all global key bindings + " (let [keys (or config.keys []) bindings (bind-global-keys keys)] (fn cleanup diff --git a/lib/hyper.fnl b/lib/hyper.fnl index 50f0076..89024c4 100644 --- a/lib/hyper.fnl +++ b/lib/hyper.fnl @@ -8,7 +8,9 @@ ;; - Bind a key or a combination of keys to trigger a hyper mode. ;; - Often this is cmd+shift+alt+ctrl ;; - Or a virtual F17 key if using something like Karabiner Elements -;; - The goal is to have a mode no other apps will be listening for +;; - The goal is to give you a whole keyboard worth of bindings that don't +;; conflict with any other apps. +;; - In config.fnl, put :hyper in a global key binding's mods list like [:hyper] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (var hyper (hs.hotkey.modal.new)) @@ -16,16 +18,31 @@ (fn enter-hyper-mode [] + " + Globally enables hyper mode + Only performs a side effect of marking the mode as enabled and enabling the + hotkey modal. + " (set enabled true) (: hyper :enter)) (fn exit-hyper-mode [] + " + Globally disables hyper mode + Only performs a side effect of marking the mode as not enabled and exits + hyper mode + " (set enabled false) (: hyper :exit)) (fn unbind-key [key] + " + Remove a binding from the hyper hotkey modal + Performs a side effect when a binding matches a target key + Side effect: Changes hotkey modal + " (when-let [binding (find (fn [{:msg msg}] (= msg key)) hyper.keys)] @@ -33,6 +50,11 @@ (fn bind [key f] + " + Bind a key on the hyper hotkey modal + Takes a key string and a function to call when key is pressed + Returns a function to remove the binding for this key. + " (: hyper :bind nil key nil f) (fn unbind [] @@ -40,10 +62,17 @@ (fn bind-spec [{:key key - :mods mods :press press-f :release release-f :repeat repeat-f}] + " + Creates a hyper hotkey modal binding based on a binding spec table + Takes a table: + - key A hotkey + - press A function to bind when the key is pressed down + - release A function to bind when the key is released + - repeat A function to bind when thek ey is repeated + " (: hyper :bind nil key press-f release-f repeat-f) (fn unbind [] @@ -51,6 +80,14 @@ (fn init [config] + " + Initializes the hyper module + - Binds the hyper keys defined in config.fnl + - Uses config.fnl :hyper as the key to trigger hyper mode + A default like :f17 or :f18 is recommended + - Binds the config.hyper key to enter hyper mode on press and exit upon + release. + " (let [h (or config.hyper {})] (hs.hotkey.bind (or h.mods []) h.key @@ -59,6 +96,10 @@ (fn enabled? [] + " + An API function to check if hyper mode is enabled + Returns true if hyper mode is enabled + " (= enabled true)) diff --git a/lib/lifecycle.fnl b/lib/lifecycle.fnl index 998a6c5..ad57e06 100644 --- a/lib/lifecycle.fnl +++ b/lib/lifecycle.fnl @@ -1,8 +1,27 @@ (local {:do-action do-action} (require :lib.bind)) (local log (hs.logger.new "lifecycle.fnl" "debug")) + +" +Functions for calling lifecycle methods of config.fnl local app configuration or +lifecycle methods assigned to a specific modal menu in config.fnl. +{:key \"emacs\" + :launch (fn [] (hs.alert \"Launched emacs\")) + :activate (fn [] (hs.alert \"Entered emacs\")) + :deactivate (fn [] (hs.alert \"Leave emacs\")) + :exit (fn [] (hs.alert \"Closed emacs\"))} +Meant for internal use only. +" + (fn do-method [obj method-name] + " + Takes a app menu table from config.fnl + Calls the lifecycle function if a function instance or resolves it to an + action if an action string was provided like \"lib.lifecycle:do-method\" + Takes a config.fnl app table and a method name string to try and call. + Returns the return value of calling the provided lifecycle function. + " (let [method (. obj method-name)] (match (type method) :function (method obj) @@ -14,31 +33,37 @@ (fn activate-app [menu] + "Calls :activate method on an app in config.fnl when focused on by user" (when (and menu menu.activate) (do-method menu :activate))) (fn close-app [menu] + "Calls the :close method on an app in config.fnl when closed by the user" (when (and menu menu.close) (do-method menu :close))) (fn deactivate-app [menu] + "Calls the :deactivate method on a config.fnl app when user blurs the app" (when (and menu menu.deactivate) (do-method menu :deactivate))) (fn enter-menu [menu] + "Calls the :enter lifecycle method on a modal menu table in config.fnl" (when (and menu menu.enter) (do-method menu :enter))) (fn exit-menu [menu] + "Calls the :exit lifecycle method on a modal menu table defined in config.fnl" (when (and menu menu.exit) (do-method menu :exit))) (fn launch-app [menu] + "Calls the :launch app table in config.fnl when user opens the app." (when (and menu menu.launch) (do-method menu :launch))) diff --git a/lib/macros.fnl b/lib/macros.fnl index 3c2d01e..ed7d8b2 100644 --- a/lib/macros.fnl +++ b/lib/macros.fnl @@ -1,5 +1,16 @@ (fn when-let [[var-name value] body1 ...] + " + Macro to set a local value and perform the body when the local value is truthy + Takes a vector to assign a local var to a value and any number of body forms + Returns the return value of the last body form executed + + Example: + (when-let [x true] + (hs.alert \"x is true\") + \"hello world\") + ;; => \"hello world\" + " (assert body1 "expected body") `(let [,var-name ,value] (when ,var-name @@ -7,6 +18,18 @@ (fn if-let [[var-name value] body1 ...] + " + Macro to set a local value and perform a body form when the value is truthy + or when it is falsey. + Takes a vector pairing a variable name to a value and at least a body form to + evaluate if the value is truthy, or another body form if value is falsey. + Returns the return value of the body form that was evaulated. + + Example: + (if-let [x 5] + (hs.alert \"I fire because 5 is a truthy value\") + (hs.alert \"I do not fire because 5 was truthy.\")) + " (assert body1 "expected body") `(let [,var-name ,value] (if ,var-name diff --git a/lib/modal.fnl b/lib/modal.fnl index 332403d..d898ee5 100644 --- a/lib/modal.fnl +++ b/lib/modal.fnl @@ -1,3 +1,14 @@ +" +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. + +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.statemachine)) (local apps (require :lib.apps)) @@ -32,6 +43,11 @@ (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 [] @@ -46,21 +62,48 @@ (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.dispatch :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.dispatch :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.dispatch :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.dispatch :start-timeout)) @@ -70,6 +113,21 @@ (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)) @@ -84,12 +142,23 @@ (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 @@ -103,6 +172,11 @@ (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)}) @@ -110,6 +184,11 @@ (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 @@ -131,6 +210,11 @@ (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 " "))] @@ -141,6 +225,12 @@ (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] @@ -162,6 +252,16 @@ :unbind-keys unbind-keys :stop-timeout stop-timeout :history history}] + " + 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 + " (call-when unbind-keys) (call-when stop-timeout) (lifecycle.exit-menu prev-menu) @@ -181,6 +281,11 @@ (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)))) @@ -193,6 +298,13 @@ (fn idle->active [state data] + " + 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 + Returns updated modal state machine state table. + " (let [{:config config :stop-timeout stop-timeout :unbind-keys unbind-keys} state @@ -207,7 +319,14 @@ (fn active->idle - [state data] + [state _] + " + Transition our modal state machine from the active, open state to idle by + closing the modal. + Takes the current modal state table. + Closes the modal, stops the close timeout, and unbinds modal keys + Returns new modal state + " (let [{:menu prev-menu} state] (hs.alert.closeAll 0) (call-when state.stop-timeout) @@ -222,6 +341,13 @@ (fn active->enter-app [state app-menu] + " + Transition our modal state machine that is already open 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 + " (let [{:config config :menu prev-menu :stop-timeout stop-timeout @@ -242,6 +368,12 @@ (fn active->leave-app [state] + " + 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. + " (let [{:config config :menu prev-menu} state] (if (= prev-menu.key config.key) @@ -251,6 +383,11 @@ (fn active->submenu [state 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. + Returns updated menu state + " (let [{:config config :menu prev-menu :stop-timeout stop-timeout @@ -270,12 +407,29 @@ (fn active->timeout [state] + " + 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 partial modal state table to merge into the modal state. + " (call-when state.stop-timeout) {:stop-timeout (timeout deactivate-modal)}) (fn submenu->previous [state] + " + 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 history} state history (slice 1 -1 history) @@ -292,6 +446,12 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; 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. (local states {:idle {:activate idle->active :enter-app noop @@ -316,6 +476,12 @@ (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 @@ -324,6 +490,14 @@ (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)) @@ -333,6 +507,13 @@ (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-state {:config config :history [] :menu nil diff --git a/lib/text.fnl b/lib/text.fnl index 0225de0..2ad5728 100644 --- a/lib/text.fnl +++ b/lib/text.fnl @@ -2,12 +2,19 @@ :merge merge :reduce reduce} (require :lib.functional)) -;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Menu Column Alignment ;; -;;;;;;;;;;;;;;;;;;;;;;;;;;; +" +These functions will align items in a modal menu based on columns. +This makes the modal look more organized because the keybindings, separator, and +action are all vertically aligned based on the longest value of each column. +" (fn max-length [items] + " + Finds the max length of each value in a column + Takes a list of key value pair lists + Returns the maximum length in characters. + " (reduce (fn [max [key _]] (math.max max (length key))) 0 @@ -15,12 +22,28 @@ (fn pad-str [char max str] + " + Pads a string to the max length with the specified char concatted to str. + Takes the char string to pad with typically \" \", the max size of the column, + and the str to concat to. + Returns the padded string + + Example: + (pad-str \".\" 6 \"hey\") + ;; => \"hey...\" + " (let [diff (- max (# str))] (.. str (string.rep char diff)))) (fn align-columns [items] + " + Align the key column of the menu items by padding out each + key string with a space to match the longest item key string. + Takes a list of modal menu items + Returns a list of veritcally aligned row strings + " (let [max (max-length items)] (map (fn [[key action]] diff --git a/lib/utils.fnl b/lib/utils.fnl new file mode 100644 index 0000000..97bc422 --- /dev/null +++ b/lib/utils.fnl @@ -0,0 +1,9 @@ +(fn global-filter + [] + " + Filter that includes full-screen apps + " + (let [filter (hs.window.filter.new)] + (: filter :setAppFilter :emacs {:allowRoles [:AXUnknown :AXStandardWindow]}))) + +{:global-filter global-filter} diff --git a/lib/utils.lua b/lib/utils.lua deleted file mode 100644 index acba942..0000000 --- a/lib/utils.lua +++ /dev/null @@ -1,114 +0,0 @@ -local utils = {} -local i - -function utils.tempNotify(timeout, notif) - notif:send() - hs.timer.doAfter(timeout, function() notif:withdraw() end) -end - -function utils.splitStr(str, sep) - if sep == nil then - sep = "%s" - end - local t = {} - i = 1 - for str in string.gmatch(str, "([^"..sep.."]+)") do - t[i] = str - i = i + 1 - end - return t -end - -function utils.strToTable(str) - local t = {} - for i = 1, #str do - t[i] = str:sub(i, i) - end - return t -end - ---- -------------------------- ---- Keymap utils ---- -------------------------- -local REPEAT_FASTER = 10 * 1000 -local REPEAT_SLOWER = 100 * 1000 -local NO_REPEAT = -1 - -local function keyStroke(mod, key, repeatDelay) - mod = mod or {} - hs.eventtap.event.newKeyEvent(mod, key, true):post() - if repeatDelay <= 0 then - repeatDelay = REPEAT_FASTER - end - hs.timer.usleep(repeatDelay) - hs.eventtap.event.newKeyEvent(mod, key, false):post() -end - -local function keyStrokeSystem(key, repeatDelay) - hs.eventtap.event.newSystemKeyEvent(key, true):post() - if repeatDelay <= 0 then - repeatDelay = REPEAT_FASTER - end - hs.timer.usleep(repeatDelay) - hs.eventtap.event.newSystemKeyEvent(key, false):post() -end - --- Map sourceKey + sourceMod -> targetKey + targetMod -utils.keymap = function(sourceKey, sourceMod, targetKey, targetMod, repeatDelay) - sourceMod = sourceMod or {} - - repeatDelay = repeatDelay or REPEAT_FASTER - noRepeat = repeatDelay <= 0 - - local fn = nil - if targetMod == nil then - -- fn = hs.fnutils.partial(keyStrokeSystem, string.upper(targetKey), repeatDelay) - fn = hs.fnutils.partial(keyStroke, nil, targetKey, repeatDelay) - else - targetMod = utils.splitStr(targetMod, '+') - fn = hs.fnutils.partial(keyStroke, targetMod, targetKey, repeatDelay) - end - if noRepeat then - return hs.hotkey.new(sourceMod, sourceKey, fn, nil, nil) - else - return hs.hotkey.new(sourceMod, sourceKey, fn, nil, fn) - end -end - ---- Filter that includes full-screen apps --- hs.window.filter.ignoreAlways['Alfred3'] = true -utils.globalFilter = function() - return hs.window.filter.new() - -- :setDefaultFilter(true, {allowRoles = 'AXStandardWindow'}) - :setAppFilter('Emacs', {allowRoles={'AXUnknown', 'AXStandardWindow'}}) - -- :setAppFilter('iTerm2', {allowRoles='AXUnknown'}) -end ----- Function ----- Applies specified functions for when window is focused and unfocused ----- ----- Parameters: ----- ----- appNames - table of appNames ----- focusedFn - function applied when one of the apps listed is focused ---- unfocusedFn - function applied when one of the apps listed is unfocused ---- ignore - reverses the order of the operation: apply given fns for any app except those listed in appNames ---- -function utils.applyAppSpecific(appNames, focusedFn, unfocusedFn, ignore) - local runFn = function(fnToRun) - local activeApp = hs.window.focusedWindow():application():name() - local is_listed = hs.fnutils.contains(appNames, activeApp) - if (ignore and not is_listed) or (not ignore and is_listed) then - if fnToRun then fnToRun() end - end - end - - utils.globalFilter() - :subscribe(hs.window.filter.windowFocused, function() runFn(focusedFn) end) - :subscribe(hs.window.filter.windowUnfocused, function() runFn(unfocusedFn) end) -end - -function utils.capitalize(str) - return string.gsub(" " .. str, "%W%l", string.upper):sub(2) -end - -return utils diff --git a/multimedia.fnl b/multimedia.fnl index f989890..0deaf99 100644 --- a/multimedia.fnl +++ b/multimedia.fnl @@ -1,26 +1,47 @@ (fn m-key [key] + " + Simulates pressing a multimedia key on a keyboard + Takes the key string and simulates pressing it for 5 ms then relesing it. + Side effectful. + Returns nil + " (: (hs.eventtap.event.newSystemKeyEvent (string.upper key) true) :post) (hs.timer.usleep 5) (: (hs.eventtap.event.newSystemKeyEvent (string.upper key) false) :post)) (fn play-or-pause [] + " + Simulate pressing the play\pause keyboard key + " (m-key :play)) (fn prev-track [] + " + Simulate pressing the previous track keyboard key + " (m-key :previous)) (fn next-track [] + " + Simulate pressing the next track keyboard key + " (m-key :next)) (fn volume-up [] + " + Simulate pressing the volume up key + " (m-key :sound_up)) (fn volume-down [] + " + Simulate pressing the volume down key + " (m-key :sound_down)) {:play-or-pause play-or-pause diff --git a/slack.fnl b/slack.fnl index c08c840..b655b56 100644 --- a/slack.fnl +++ b/slack.fnl @@ -1,5 +1,8 @@ (local windows (require :windows)) +" +Slack functions to make complex or less accessible features more vim like! +" ;; Utils @@ -22,8 +25,10 @@ (fn thread [] - ;; Start a thread on the last message. It doesn't always work, because of - ;; stupid Slack App inconsistency with TabIndexes + " + Start a thread on the last message. It doesn't always work, because of + stupid Slack App inconsistency with TabIndexes + " (hs.eventtap.keyStroke [:shift] :f6) (hs.eventtap.keyStroke [] :right) (hs.eventtap.keyStroke [] :space)) @@ -47,6 +52,8 @@ [] (hs.eventtap.keyStroke [:shift] :pagedown)) +;; Scrolling functions + (fn scroll-slack [dir] (windows.set-mouse-cursor-at :Slack) diff --git a/vim.fnl b/vim.fnl index e0d0f74..beba670 100644 --- a/vim.fnl +++ b/vim.fnl @@ -13,11 +13,25 @@ (local {:bind-keys bind-keys} (require :lib.bind)) (local log (hs.logger.new "vim.fnl" "debug")) +" +Create a vim mode for any text editor! +- Modal editing like NORMAL, VISUAL, and INSERT mode. +- vim key navigation like hjkl +- Displays a box to display which mode you are in +- Largely experimental + +TODO: Create another state machine system to support key chords for bindings + like gg -> scroll to top of document. + - Should work a lot like the menu modal state machine where you can + endlessly enter recursive submenus +" + ;; Debug (local hyper (require :lib.hyper)) (var fsm nil) +;; Box shapes for displaying current mode (local shape {:x 900 :y 900 :h 40 @@ -383,6 +397,16 @@ (fn init [config] + " + Initialize vim mode only enables it if {:vim {:enabled true}} is in config.fnl + Takes config.fnl table + Performs side-effects: + - Creates a state machine to track which mode we are in and switch bindings + accordingly + - Creates a screen watcher so it can move the mode UI to the currently active + screen. + Returns function to cleanup watcher resources + " (let [initial {:config config :mode :disabled :unbind-keys nil} diff --git a/windows.fnl b/windows.fnl index 2d54725..23c85d8 100644 --- a/windows.fnl +++ b/windows.fnl @@ -1,5 +1,6 @@ (local {:filter filter :get-in get-in} (require :lib.functional)) +(local {:global-filter global-filter} (require :lib.utils)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -10,6 +11,10 @@ (fn history.push [self] + " + Append current window frame geometry to history. + self refers to history table instance + " (let [win (hs.window.focusedWindow) id (: win :id) tbl (. self id)] @@ -23,6 +28,10 @@ (fn history.pop [self] + " + Go back to previous window frame geometry in history. + self refers to history table instance + " (let [win (hs.window.focusedWindow) id (: win :id) tbl (. self id)] @@ -95,11 +104,10 @@ (fn jump-to-last-window [] - (let [utils (require :lib.utils)] - (-> (utils.globalFilter) - (: :getWindows hs.window.filter.sortByFocusedLast) - (. 2) - (: :focus)))) + (-> (global-filter) + (: :getWindows hs.window.filter.sortByFocusedLast) + (. 2) + (: :focus))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -108,6 +116,15 @@ (fn jump-window [arrow] + " + Navigate to the window nearest the current active window + For instance if you open up emacs to the left of a web browser, activate + emacs, then run (jump-window :l) hammerspoon will move active focus + to the browser. + Takes an arrow like :h :j :k :l to support vim key bindings. + Performs side effects + Returns nil + " (let [dir {:h "West" :j "South" :k "North" :l "East"} space (. (hs.window.focusedWindow) :filter :defaultCurrentSpace) fn-name (.. :focusWindow (. dir arrow))] @@ -137,6 +154,9 @@ false)) (fn jump [] + " + Displays hammerspoon's window jump UI + " (let [wns (->> (hs.window.allWindows) (filter allowed-app?))] (hs.hints.windowHints wns nil true))) @@ -155,6 +175,10 @@ (fn grid [method direction] + " + Moves, expands, or shrinks a the active window by the next grid dimension + Grid settings are specified in config.fnl. + " (let [fn-name (.. method direction) f (. hs.grid fn-name)] (f (hs.window.focusedWindow)))) @@ -164,13 +188,26 @@ ;; Resize window by half ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(fn rect [rct] +(fn rect + [rct] + " + Change a window's rect geometry which includes x, y, width, and height + Takes a rectangle table + Performs side-effects to move\resize the active window and update history. + Returns nil + " (: history :push) (let [win (hs.window.focusedWindow)] (when win (: win :move rct)))) (fn resize-window-halve [arrow] + " + Resize a window by half the grid dimensions specified in config.fnl. + Takes an :h :j :k or :l arrow + Performs a side effect to resize the active window's frame rect + Returns nil + " (: history :push) (rect (. arrow-map arrow :half))) @@ -197,6 +234,16 @@ (fn resize-by-increment [arrow] + " + Resize the active window by the next window increment + Let's say we make the grid dimensions 4x4 and we place a window in the 1x1 + meaning first column in the first row. + We then resize an increment right. The dimensions would now be 2x1 + + Takes an arrow like :h :j :k :l + Performs a side-effect to resize the current window to the next grid increment + Returns nil + " (let [directions {:h "Left" :j "Down" :k "Up" @@ -231,6 +278,12 @@ (fn resize-window [arrow] + " + Resizes a window against the grid specifed in config.fnl + Takes an arrow string like :h :k :j :l + Performs a side effect to resize the current window. + Returns nil + " (: history :push) ;; hs.grid.resizeWindowShorter/Taller/Thinner/Wider (grid :resizeWindow (. arrow-map arrow :resize))) @@ -258,6 +311,15 @@ (fn move-screen [method] + " + Moves a window to the display in the specified direction + :north ^ :south v :east -> :west <- + Takes a method name of the hammer spoon window instance. + You probably will not be using this function directly. + Performs a side effect that will move a window the next screen in specified + direction. + Returns nil + " (let [window (hs.window.focusedWindow)] (: window method nil true))) @@ -284,6 +346,12 @@ (fn init [config] + " + Initializes the windows module + Performs side effects: + - Set grid margins from config.fnl like {:grid {:margins [10 10]}} + - Set the grid dimensions from config.fnl like {:grid {:size \"3x2\"}} + " (hs.grid.setMargins (or (get-in [:grid :margins] config) [0 0])) (hs.grid.setGrid (or (get-in [:grid :size] config) "3x2"))) From f2ddc3faf61d16fb975bf69400451f262e33976b Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 27 Jan 2020 15:24:15 -0500 Subject: [PATCH 24/34] Fixes default config agzam/spacehammer#37 - Corrects syntax error in config.fnl caused by merge conflict - Please remove your ~/.spacehammer/config.fnl to get fixed version --- config.fnl | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/config.fnl b/config.fnl index 4fbfefa..cf3d6fd 100644 --- a/config.fnl +++ b/config.fnl @@ -328,10 +328,6 @@ :action "windows:jump"} {:key :m :title "Media" - ;; :enter (fn [menu] - ;; (print "Entering menu: " (hs.inspect menu))) - ;; :exit (fn [menu] - ;; (print "Exiting menu: " (hs.inspect menu))) :items media-bindings} {:key :x :title "Emacs" @@ -346,10 +342,10 @@ :action "apps:next-app"} {:mods [:cmd] :key :p - :action "apps:prev-app"}] + :action "apps:prev-app"} {:mods [:cmd :ctrl] :key "`" - :action toggle-console}) + :action toggle-console}]) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 1369a83b90b6df421d6013b228dc72564bc0fb76 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 27 Jan 2020 15:58:32 -0500 Subject: [PATCH 25/34] Fixed Chrome browser tab shortcuts - Escaped \ char used in docstring --- chrome.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chrome.fnl b/chrome.fnl index 1f4b92e..8cb4e08 100644 --- a/chrome.fnl +++ b/chrome.fnl @@ -5,7 +5,7 @@ [] " Activate the Chrome > File > Open Location... action which moves focus to the - address\search bar. + address\\search bar. Returns nil " (when-let [app (: (hs.window.focusedWindow) :application)] From a24242d667b0e9ad7d66bdabb81e9e9efa6ac07e Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Mon, 27 Jan 2020 13:42:51 -0800 Subject: [PATCH 26/34] fix: escape backslash --- multimedia.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multimedia.fnl b/multimedia.fnl index 0deaf99..72c8677 100644 --- a/multimedia.fnl +++ b/multimedia.fnl @@ -12,7 +12,7 @@ (fn play-or-pause [] " - Simulate pressing the play\pause keyboard key + Simulate pressing the play\\pause keyboard key " (m-key :play)) From 8b056f4c20c193694350f182e5fd002c46abec81 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 27 Jan 2020 16:14:16 -0500 Subject: [PATCH 27/34] Switches default bindings to use the option (alt) key instead of cmd - Opening the menu is `alt + space` - Switching to the previous app is `alt + p` - Switching to the next app is `alt + n` Attempts to fix agzam/spacehammer#30 --- config.fnl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config.fnl b/config.fnl index cf3d6fd..96e4a7c 100644 --- a/config.fnl +++ b/config.fnl @@ -334,13 +334,13 @@ :items emacs-bindings}]) (local common-keys - [{:mods [:cmd] + [{:mods [:alt] :key :space :action "lib.modal:activate-modal"} - {:mods [:cmd] + {:mods [:alt] :key :n :action "apps:next-app"} - {:mods [:cmd] + {:mods [:alt] :key :p :action "apps:prev-app"} {:mods [:cmd :ctrl] From 40cb5e8de9b518f436af31d833786b5547d7e810 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 1 Feb 2020 16:16:18 -0800 Subject: [PATCH 28/34] tab-switcher keys (use alt instead of cmd) --- config.fnl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.fnl b/config.fnl index 96e4a7c..1a626fc 100644 --- a/config.fnl +++ b/config.fnl @@ -356,11 +356,11 @@ [{:mods [:cmd :shift] :key :l :action "chrome:open-location"} - {:mods [:cmd] + {:mods [:alt] :key :k :action "chrome:next-tab" :repeat true} - {:mods [:cmd] + {:mods [:alt] :key :j :action "chrome:prev-tab" :repeat true}]) From 88461ab75dffef1adbc1eb8ee7b5398d204a1e9b Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 1 Feb 2020 16:17:32 -0800 Subject: [PATCH 29/34] some readme improvements --- README.ORG | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/README.ORG b/README.ORG index fe2abaa..4cc1ad9 100644 --- a/README.ORG +++ b/README.ORG @@ -5,16 +5,21 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon #+END_SRC ** Rationale - I love Vim and I love Vim keybindings, I use them wherever I can, and I needed to find a better, more efficient way to do my work in OSX. + Keyboard-oriented workflows are often far more efficient and less frustrating than similar mouse-driven techniques. However, the most popular strategy in that space is to use a multitude of keyboard shortcuts. And obviously, that approach is not very scalable. You start adding keyboard shortcuts for various actions, and soon you run out of ideas. - Ideally, I wanted to stay in the home row and control as much as I could with only =h/j/k/l= keys. Adding tens (possibly hundreds) of key-combo shortcuts for every single desirable action is not a right solution. + Ideas of command composability (first explored in ~Vi~ and later expanded in its successor ~Vim~), although do require some initial learning and getting used to, allow you to expand your keyboard-oriented workflow with a minimal effort to memorize keys. There's so much you can do with using ~h/j/k/l~ keys alone. - Also, unlike vanilla Vim - a single modal (to switch from Normal to Edit to Select mode) is often not enough. So I've taken an approach done in [[http://spacemacs.org/][Spacemacs]]. In Spacemacs - there is a single primary modifier key - SPACE. Of course, I couldn't just use SPACE - because none of the modern operating systems can infer the current mode of the operation - /either you are typing or navigating, is cursor focused on an input field, etc./. So I had to use a key-combo. /Default key-combo in Spacehammer is Cmd+SPC./ + However, the "one-dimensional" approach utilized in vanilla Vim, where a single modal (to switch from Normal to Edit to Select mode) is used, also has limitations. But basic ideas of modality can be expanded further. [[http://spacemacs.org/][Spacemacs]] project is an excellent example of where that was done. In Spacemacs - there is a single primary "modifier" key - ~SPACE~. To trigger an action user is required to press a mnemonically recognizable combination of keys (that usually starts with ~space~ key), e.g., ~SPC w m~ is used to maximize current window/buffer. + + This project explores these ideas to allow you to take your keyboard-driven workflow to the next level. Jumping between applications, controlling the size and position of their windows, searching for things, etc. - everything follows simple, mnemonic semantics. It allows you to keep your fingers on the home row. And no need to memorize a myriad of keystrokes. Or to drag your hand to reach for mouse/touchpad/arrow keys - that inevitably slows you down. to reach for mouse/touchpad/arrow keys - that inevitably slows you down. - Hammerspoon is an incredibly powerful tool, and it allowed me to take my workflow to a completely different level. Jumping between apps, controlling windows, searching things, etc. have become so much easier - everything follows simple semantics of keeping your fingers on the home row. And no need to memorize myriad of keystrokes. Or to drag your hand to reach for mouse/touchpad/arrow keys - that inevitably slows you down. *** Fennel - Spacehammer initially was written in Lua (as the majority of Hammerspoon configs), but later I have discovered [[https://fennel-lang.org/][Fennel]] - tiny Lisp that compiles into Lua. I decided to re-write it in Fennel. There is nothing wrong with Lua - I simply prefer Lisp - it is compact, has zero overhead and makes me more productive. - Lua and Fennel can live together in the same config, I still have non-critical parts that I have not yet re-written in Fennel. Also - if you are interested (now outdated) version of Spacehammer lives in [[https://github.com/agzam/spacehammer/tree/lua][lua]] branch + Spacehammer initially was written in Lua (as the majority of Hammerspoon + configs), but later was completely re-written in + [[https://fennel-lang.org/][Fennel]] - tiny Lisp that compiles into Lua. + There is nothing wrong with Lua, but Lisp has many benefits (sadly often + overlooked and ignored by majority of programmers today). Switching to + Fennel allowed us to keep the code more structured and concise. ** Prerequisites *** Install Hammerspoon @@ -29,15 +34,13 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon luarocks install fennel #+end_src -** Important note! - Main key combo is set to =Cmd+SPC=. By default, in OS X it's used for something else (usually for Spotlight). +** Important note about the Lead key + Lead key combo is by default set to =Option+SPC=, but it can be re-configured in ~~/.spacehammer/config.fnl~ and to be set for example to =Cmd+SPC=. Be warned though, in OS X =Cmd+SPC= is usually used for Spotlight. - *In order for it to work - you either will have to rebind it in your system or choose a different key-combo and change it in the config* + *In order for it to work - you will have to rebind it in your system* *** For changing it in the system preferences - Go to your Preferences/Keyboard, find associated keybinding and change it. Unfortunately, simply disabling it isn't enough. You'd have to set it to be something else e.g. =Ctrl+Cmd+Shift+\= or whatever (I dunno - use your imagination), it doesn't really matter, since you can then uncheck the checkbox. -*** For changing it in Spacehammer config - If you find =Cmd+SPC= to be inconvenient - modify relevant code in =~/.hammerspoon/core.fnl= and set it to whatever keybinding you like. + Go to your Preferences/Keyboard, find =Cmd+SPC= keybinding and change it. Unfortunately, simply disabling it sometimes is not enough. You'd have to set it to be something else e.g. =Ctrl+Cmd+Shift+\= or anything else , it doesn't really matter, since you can then un-check the checkbox and disable it. ** Features **** =Cmd+SPC w= - Window management - =hjkl= - moving windows around halves of the screen From f1a4385f508a62670214e1c37bf9f4afaa34a98e Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sun, 2 Feb 2020 12:21:16 -0800 Subject: [PATCH 30/34] Further readme improvements. Formatting, etc. --- README.ORG | 131 ++++++++++++++++++++++++----------------------------- 1 file changed, 58 insertions(+), 73 deletions(-) diff --git a/README.ORG b/README.ORG index 4cc1ad9..b9708ed 100644 --- a/README.ORG +++ b/README.ORG @@ -1,17 +1,13 @@ [[http://www.hammerspoon.org/][Hammerspoon]] config inspired by [[http://spacemacs.org/][Spacemacs]] -#+BEGIN_SRC bash -git clone https://github.com/agzam/spacehammer ~/.hammerspoon -#+END_SRC - ** Rationale - Keyboard-oriented workflows are often far more efficient and less frustrating than similar mouse-driven techniques. However, the most popular strategy in that space is to use a multitude of keyboard shortcuts. And obviously, that approach is not very scalable. You start adding keyboard shortcuts for various actions, and soon you run out of ideas. + Keyboard-oriented workflows are often far more efficient and less frustrating than similar mouse-driven techniques. However, the most popular strategy in that space is to use a multitude of keyboard shortcuts. And obviously, that approach is not very scalable. You start adding keyboard shortcuts for various actions, and soon you run out of ideas. You inevitably will be blocked by conflicting shortcuts. Ideas of command composability (first explored in ~Vi~ and later expanded in its successor ~Vim~), although do require some initial learning and getting used to, allow you to expand your keyboard-oriented workflow with a minimal effort to memorize keys. There's so much you can do with using ~h/j/k/l~ keys alone. - However, the "one-dimensional" approach utilized in vanilla Vim, where a single modal (to switch from Normal to Edit to Select mode) is used, also has limitations. But basic ideas of modality can be expanded further. [[http://spacemacs.org/][Spacemacs]] project is an excellent example of where that was done. In Spacemacs - there is a single primary "modifier" key - ~SPACE~. To trigger an action user is required to press a mnemonically recognizable combination of keys (that usually starts with ~space~ key), e.g., ~SPC w m~ is used to maximize current window/buffer. + However, the "one-dimensional" approach utilized in vanilla Vim, where a single modal (to switch from Normal to Edit to Select mode) is used, also has limitations. Fortunately, basic ideas of modality can be expanded further. [[http://spacemacs.org/][Spacemacs]] project is an excellent example of where that was done. In Spacemacs - there is a single primary "modifier" key - ~SPACE~. To trigger an action, user is required to press a mnemonically recognizable combination of keys (that usually starts with ~space~ key), e.g., ~SPC w m~ is used to maximize current window/buffer. - This project explores these ideas to allow you to take your keyboard-driven workflow to the next level. Jumping between applications, controlling the size and position of their windows, searching for things, etc. - everything follows simple, mnemonic semantics. It allows you to keep your fingers on the home row. And no need to memorize a myriad of keystrokes. Or to drag your hand to reach for mouse/touchpad/arrow keys - that inevitably slows you down. to reach for mouse/touchpad/arrow keys - that inevitably slows you down. + Spacehammer project explores these ideas to allow you to take your keyboard-driven workflow to the next level. Jumping between applications, controlling the size and position of their windows, searching for things, etc. - everything follows simple, mnemonic semantics. It lets you keep your fingers on the home row and liberates you from having to memorize a myriad of keystrokes. Or to drag your hand to reach for mouse/touchpad/arrow keys - that inevitably slows you down. *** Fennel Spacehammer initially was written in Lua (as the majority of Hammerspoon @@ -21,9 +17,9 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon overlooked and ignored by majority of programmers today). Switching to Fennel allowed us to keep the code more structured and concise. -** Prerequisites +** Installation *** Install Hammerspoon - You can use brew: + You can use [[https://brew.sh/][brew]]: #+begin_src bash brew cask install hammerspoon #+end_src @@ -33,101 +29,90 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon luarocks install fennel #+end_src +*** Clone Spacehammer + #+begin_src bash + git clone https://github.com/agzam/spacehammer ~/.hammerspoon + #+end_src +** LEAD keybinding + =LEAD= is the main and major keybinding that invokes main Spacehammer modal. By default set to =Option+SPC=, but it can be re-configured in =~/.spacehammer/config.fnl=, and to be set for example to =Cmd+SPC=. Be warned though, in OS X =Cmd+SPC= is usually used for Spotlight. -** Important note about the Lead key - Lead key combo is by default set to =Option+SPC=, but it can be re-configured in ~~/.spacehammer/config.fnl~ and to be set for example to =Cmd+SPC=. Be warned though, in OS X =Cmd+SPC= is usually used for Spotlight. - - *In order for it to work - you will have to rebind it in your system* + In order for Cmd+SPC to work as =LEAD= - you will have to rebind it in your system -*** For changing it in the system preferences - Go to your Preferences/Keyboard, find =Cmd+SPC= keybinding and change it. Unfortunately, simply disabling it sometimes is not enough. You'd have to set it to be something else e.g. =Ctrl+Cmd+Shift+\= or anything else , it doesn't really matter, since you can then un-check the checkbox and disable it. +***** For changing it in the system preferences + Go to your Preferences/Keyboard, find =Cmd+SPC= keybinding and change it. Unfortunately, simply disabling it sometimes is not enough. You'd have to set it to be something else e.g. =Ctrl+Cmd+Shift+\= or anything else , it doesn't really matter, since you can then un-check the checkbox and disable it. ** Features -**** =Cmd+SPC w= - Window management +**** =LEAD w= - Window management - =hjkl= - moving windows around halves of the screen - =Ctrl + hjkl= - for jumping between application windows (handy for side by side windows) - =w= - jump to previous window - =n/p= - moving current window to prev/next monitor - - =Alt + hjkl= - moving in increments (works across monitors) + - =Option + hjkl= - moving in increments (works across monitors) - =Shift + hjkl= - re-sizing active window - =g= - re-sizing with [[http://www.hammerspoon.org/docs/hs.grid.html][hs.grid]] - =m= - maximize active window - =c= - center active window - =u= - undo last window operation (similar to Spacemacs's =SPC w u=) -**** =Cmd+SPC a= - Apps (quick jump) +**** =LEAD a= - Apps (quick jump) - =e= - Emacs - =g= - Chrome - =i= - iTerm - =s= - Slack - you can add more, also try =Cmd SPC j j= + you can add more, also try =LEAD j j= -**** =Cmd+SPC SPC= - open Alfred search bar - basically pressing =SPC= anytime in any modal takes you to Alfred search popup +**** =LEAD SPC= - open Alfred search bar + pressing =SPC= in the main modal takes you to Alfred search popup, pressing =SPC= in other modals returns to previous modal. -**** =Cmd+SPC m= - multimedia controls - Why just not use media-keys? +**** =LEAD m= - multimedia controls + Why not use media-keys? a) because different external keyboards impose their own ways to control media. - b) I'd like to keep fingers on the home row + b) Spacehammer allows you to keep fingers on the home row. - it's configured to work with Google Play Music Desktop App. If you want it to be Spotify or anything else - change the value of =music-app= in =multimedia.fnl= module + By default =LEAD m a= - =jump to music ap= is configured to work with Spotify, but you can change that in =~./spacehammer/config.fnl= ** Other features -**** Alternative App Switcher =Cmd n/p= -**** Simple tab switcher for Chrome and iTerm =Cmd j/k= - =Cmd l= in Chrome is re-mapped to =Cmd+Shift l= -**** Simple vi-mode - - =h/j/k/l= - simple left/right/up/down - - =w/b= - word wise forward back - - =Shift h/j/k/l= - selecting things - - These can be disabled in certain apps (by default they they are ignored in Emacs) -**** Slack Desktop Client enhancements - - Switching to Slack via "Apps" modal =CMD+SPC a s= - automatically opens Slack's "Jump to" dialog - - Scrolling current Slack thread with =C-j/C-k= or =C-e/C-y= - - Jumping to the end of the thread with =Cmd-g= - - Adding emoji to the last message - =Cmd-r= (sorry, but default =Cmd-Shift+\= is horribly inconvenient) - - =C-o/C-i= - jumping back and forth in history -** TODO - - [ ] Chord function to better support keys like =jk= =fd= or =gg= - - [ ] =jk= or =fd= to exit modals (like =evil-escape-key-sequence= in Emacs) - - [ ] Window configuration profiles (similar to Layouts feature in Spacemacs) - - [ ] Disable non-available keys in a modal. Keys that not listed should be simply ignored see #1 - - [ ] Another thing I want is to be able to toggle ChromeDevtools panel - this is somewhat tricky, see [[https://github.com/Hammerspoon/hammerspoon/issues/1506][this issue]] - - [ ] Better than default HUD display (something less obtrusive than ~hs.alert~ would be nice +**** Alternative App Switcher =Option n/p= +**** Simple tab switcher for Chrome and iTerm =Option j/k= +**** Slack Desktop App enhancements + - Scroll trough current Slack thread =Ctrl-j/Ctrl-k= (slow) or =Ctrl-e/Ctrl-y= (fast) + - Jump to the end of the thread with =Cmd-g= + - Adding emoji to the last message - =Cmd-r= (Slack's default =Cmd-Shift+\= is quite inconvenient) + - =Ctrl-o/Ctrl-i= - jumping back and forth in history ** Customizing *** Update menus, menu items, bindings, and app specific features All menu, app, and key bindings are defined in =~/.spacehammer/config.fnl=. That is your custom config and will be safe from any upstream changes to the default config.fnl. + /The reason for to keep it in its own directory, so it can be maintained in version-control/. **** Modal Menu Items - Menu items are listed when you press =cmd+space= and can be nested. + Menu items are listed when you press =LEAD= and they can be nested. + Items map a key binding to an action, either a function or ="module:function-name"= string. Menu items may either define an action or a table list of items. - For menu items that should be repeated, add =repeatable: true= to the item table. The repeatable flag keeps the menu option after the action has been triggered. Repeating a menu item is ideal for actions like window layouts where you may wish to move the window from the left third to the right third. #+BEGIN_SRC fennel - (local launch-alfred {:title "Alfred" - :key :SPACE + (local launch-alfred {:title "Alfred" + :key :SPACE :action (fn [] (hs.appplication.launchOrFocus "Alfred"))}) - (local slack-jump {:title "Slack" - :key :s + (local slack-jump {:title "Slack" + :key :s :action "slack:quick-switcher"}) - (local window-inc {:title "Window Halves" - :mods [:cmd] - :key :l + (local window-inc {:title "Window Halves" + :mods [:cmd] + :key :l :action "windows:resize-inc-right"}) (local submenu {:title "Submenu" - :key :t - :items [{:key :m - :title "Show a message" + :key :t + :items [{:key :m + :title "Show a message" :action (fn [] (alert "I'm a submenu action"))}]}) (local config {:items [launch-alfred slack-jump @@ -148,25 +133,25 @@ git clone https://github.com/agzam/spacehammer ~/.hammerspoon #+BEGIN_SRC fennel (local config {:hyper {:mods [:cmd :ctrl :alt :shift]} - :keys [{:mods [:cmd] - :key :space - :action "lib.modal:activate-modal"} - {:mods [:cmd] - :key :h - :action "chrome:prev-tab" - :repeat true} - {:mods [:hyper] - :key :f - :action (fn [] (alert "Haha you pressed f!"))}]}) + :keys [{:mods [:cmd] + :key :space + :action "lib.modal:activate-modal"} + {:mods [:cmd] + :key :h + :action "chrome:prev-tab" + :repeat true} + {:mods [:hyper] + :key :f + :action (fn [] (alert "Haha you pressed f!"))}]}) #+END_SRC **** App specific customizations Configure separate menu options and key bindings while specified apps are active. Additionally, several lifecycle functions or action strings may be provided for each app. - - `:activate` When an application receives keyboard focus - - `:deactivate` When an application loses keyboard focus - - `:launch` When an application is launched - - `:close` When an application is terminated + - ~:activate~ When an application receives keyboard focus + - ~:deactivate~ When an application loses keyboard focus + - ~:launch~ When an application is launched + - ~:close~ When an application is terminated #+BEGIN_SRC fennel (local emacs-config From a8f5b01bef24bcff3056e45fc120262823fd28fa Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sun, 2 Feb 2020 15:49:15 -0800 Subject: [PATCH 31/34] adds Firefox --- config.fnl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config.fnl b/config.fnl index 1a626fc..3e4a2de 100644 --- a/config.fnl +++ b/config.fnl @@ -382,6 +382,11 @@ :keys browser-keys :items browser-items}) +(local firefox-config + {:key "Firefox" + :keys browser-keys + :items browser-items}) + (local emacs-config {:key "Emacs" :activate (fn [] @@ -467,6 +472,7 @@ (local apps [brave-config chrome-config + firefox-config emacs-config grammarly-config hammerspoon-config From b26370e8bf89a1c0d5b72bcb71e9068aa5beb0bc Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Tue, 4 Feb 2020 22:44:17 -0800 Subject: [PATCH 32/34] default music app is set to Spotify I think this would be the most popular choice --- config.fnl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config.fnl b/config.fnl index 3e4a2de..a2ee745 100644 --- a/config.fnl +++ b/config.fnl @@ -104,8 +104,7 @@ ;; If you would like to customize this we recommend copying this file to ;; ~/.hammerspoon/private/config.fnl. That will be used in place of the default ;; and will not be overwritten by upstream changes when spacehammer is updated. -(local music-app - "Google Play Music Desktop Player") +(local music-app "Spotify") (local return {:key :space From af7e7a9ac034dc52235784172dfc85122474b2f2 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Tue, 4 Feb 2020 23:06:13 -0800 Subject: [PATCH 33/34] changelog updates --- CHANGELOG.ORG | 81 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.ORG b/CHANGELOG.ORG index f79d8a5..d54f067 100644 --- a/CHANGELOG.ORG +++ b/CHANGELOG.ORG @@ -1,34 +1,47 @@ -- [2017-06-25 Sun] - - Sierra compatibility - /*Since Karabiner is not compatible anymore (starting with Sierra), had to find a way to get similar features*/ - - ~keybdings~ module - - App switcher - =Cmd+j/k= - - Simple tab switcher for Chrome and iTerm2 - =Cmd+h/l= - - Simple =Vi-mode= - =Alt+j/k/l/m= - - App specific keybindings - - Changed Slack reaction key to =C-r=, so =Cmd+i= can be used to switch between current application windows -- [2017-10-14 Sat] - - Improved modal system - simplifies adding and extending modals - - Emacs module - currently invokes Emacs to enable system-wide org-capture. Accompanying emacs-lisp code can be found [[https://github.com/agzam/dot-spacemacs/blob/master/layers/ag-org/funcs.el#L144][here]] -- [2019-05-06 Mon] - - Rewrote everything in Fennel -- [2019-05-07 Tue] - - Added local modals - - Grammarly + Emacs interaction -- [2019-06-23 Sun] - - Auxiliary Emacs package, spacehammer.el - - Fixes Local app-keys are leaking #15 -- [2019-06-25 Tue] - - Emacs improvements - + run-emacs-fn - + full-screen - + vertical-split-with-emacs -- [2019-07-19 Fri] - - Refactored… - + Modals - + Configuration - + Keybindings - + App specific keybindings - + App specific modals - + Vim mode +* Note: sorted starting from the most recent changes + - [2020-02-04 Tue] + - New, completely revamped modal engine - @eccentric-j + - Improved state-machine implementation - @eccentric-j + - ~/.spacehammer.d/config for localized customization - @eccentric-j + - Nicer HUD - @eccentric-j + - Added lots of docstrings - @eccentric-j + - Fixed compatibility issues. Currently supported Fennel version 0.3.2 - @eccentric-j + - =LEAD= keybinding is now by default set to =Option+SPC= (used to be =Cmd+SPC=) + - App switcher keybinding is now by default set to =Option+n/p= (used to be =Cmd+n/p=) + - Tab switcher keybinding is now by default set to =Option+j/k= (used to be =Cmd+j/k=) + - Pressing =SPC= in a submodal, brings you to the previous level modal (used to open ~Alfred~) + pressing =SPC= at the top level modal still takes you to ~Alfred~ + - [2019-07-19 Fri] + - Refactored… + + Modals + + Configuration + + Keybindings + + App specific keybindings + + App specific modals + + Vim mode + - [2019-06-25 Tue] + - Emacs improvements + + run-emacs-fn + + full-screen + + vertical-split-with-emacs + - [2019-06-23 Sun] + - Auxiliary Emacs package, spacehammer.el + - Fixes Local app-keys are leaking #15 + - [2019-05-07 Tue] + - Added local modals + - Grammarly + Emacs interaction + - [2019-05-06 Mon] + - Rewrote everything in Fennel + - [2017-10-14 Sat] + - Improved modal system - simplifies adding and extending modals + - Emacs module + currently invokes Emacs to enable system-wide org-capture. Accompanying emacs-lisp code can be found [[https://github.com/agzam/dot-spacemacs/blob/master/layers/ag-org/funcs.el#L144][here]] + - [2017-06-25 Sun] + - Sierra compatibility + /*Since Karabiner is not compatible anymore (starting with Sierra), had to find a way to get similar features*/ + - ~keybdings~ module + - App switcher - =Cmd+j/k= + - Simple tab switcher for Chrome and iTerm2 - =Cmd+h/l= + - Simple =Vi-mode= - =Alt+j/k/l/m= + - App specific keybindings + - Changed Slack reaction key to =C-r=, so =Cmd+i= can be used to switch between current application windows From f951aac0eb9e91101a144c1df354a9054e03d852 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Tue, 4 Feb 2020 23:12:06 -0800 Subject: [PATCH 34/34] fix: broken windows.undo-action --- windows.fnl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows.fnl b/windows.fnl index 23c85d8..d49aa51 100644 --- a/windows.fnl +++ b/windows.fnl @@ -390,4 +390,4 @@ :resize-right resize-right :set-mouse-cursor-at set-mouse-cursor-at :show-grid show-grid - :undo undo} + :undo-action undo}