├── .gitignore ├── bin └── basedc ├── makefile ├── LICENSE ├── package.json ├── example └── basedwm.sxhkdrc ├── readme.markdown ├── wm-kit.ls └── index.ls /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin/index.js 3 | wm-kit.js 4 | -------------------------------------------------------------------------------- /bin/basedc: -------------------------------------------------------------------------------- 1 | echo "$@" > "/tmp/basedwm$DISPLAY-cmd.fifo" 2 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | all: bin/index.js wm-kit.js 2 | 3 | bin/index.js: index.ls 4 | @mkdir -p bin 5 | echo '#!/usr/bin/env node' > $@ 6 | lsc --compile --print $< >> $@ 7 | chmod +x $@ 8 | 9 | wm-kit.js: wm-kit.ls 10 | lsc --compile --print $< > $@ 11 | 12 | clean: 13 | @rm -f bin/index.js wm-kit.js 14 | 15 | .PHONY: all clean 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, An 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basedwm", 3 | "version": "0.1.2", 4 | "description": "X window manager with infinite panning desktop", 5 | "main": "bin/index.js", 6 | "scripts": { 7 | "prepublish": "make", 8 | "start": "node bin/index.js --", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "bin": { 12 | "basedwm": "./bin/index.js", 13 | "basedc": "./bin/basedc" 14 | }, 15 | "preferGlobal": true, 16 | "os": [ 17 | "linux" 18 | ], 19 | "files": [ 20 | "bin", 21 | "wm-kit.js" 22 | ], 23 | "author": "An 72 | 73 | ### Details 74 | 75 | Basedwm outputs window positions on a socket in `/tmp/wmstate.sock`, as 76 | newline-delimited [JSON][14] objects. Every object has a property `id` with 77 | the window's ID, and a property `action` representing the event type. 78 | 79 | The possible `action`s and their additional properties are: 80 | 81 | | action | description | additional properties | 82 | | ------------ | ----------------------- | --------------------------- | 83 | | focus | window gained focus | none | 84 | | destroy | window was destroyed | none | 85 | | existing-add | initial window position | `x`, `y`, `width`, `height` | 86 | | add | window was added | `x`, `y`, `width`, `height` | 87 | | move | window was moved | `x`, `y` | 88 | | resize | window was resized | `width`, `height` | 89 | 90 | `existing-add`-events are only sent immediately after connecting. This lets 91 | any consuming program initialise its copy of the window positions. 92 | 93 | The `x` and `y` properties indicate the absolute coordinates of the window's 94 | top-left corner, relative to the screen's top-left corner. The `width` and 95 | `height` properties indicate the absolute dimensions of the window. 96 | 97 | You can easily survey the output using `socat UNIX:/tmp/wmstate.sock -`. 98 | 99 | ## Bugs 100 | 101 | Yes. 102 | 103 | ## Inspirations & thankyous 104 | 105 | Intended as a modern reinterpretation of [swm][15], with the big virtual 106 | desktop taken to the extreme. Exposing controls on a pipe/socket interface and 107 | outsourcing controls to [sxhkd][16] are ideas from [bspwm][17]. 108 | 109 | ## License 110 | 111 | [ISC][18]. 112 | 113 | [1]: https://www.npmjs.com/package/basedwm 114 | [2]: https://en.wikipedia.org/wiki/X_window_manager 115 | [3]: http://livescript.net 116 | [4]: http://en.wikipedia.org/wiki/Named_pipe 117 | [5]: https://en.wikipedia.org/wiki/Unix_domain_socket 118 | [6]: https://nodejs.org/ 119 | [7]: https://iojs.org/ 120 | [8]: https://github.com/baskerville/sxhkd 121 | [9]: example/basedwm.sxhkdrc 122 | [10]: https://en.wikipedia.org/wiki/Environment_variable 123 | [11]: https://github.com/anko/hudkit 124 | [12]: http://d3js.org/ 125 | [13]: https://cloud.githubusercontent.com/assets/5231746/8208678/c40d95a6-1500-11e5-9ecf-84aece17044e.png 126 | [14]: http://json.org/ 127 | [15]: https://en.wikipedia.org/wiki/Swm 128 | [16]: https://github.com/baskerville/sxhkd 129 | [17]: https://github.com/baskerville/bspwm 130 | [18]: LICENSE 131 | -------------------------------------------------------------------------------- /wm-kit.ls: -------------------------------------------------------------------------------- 1 | require! <[ x11 ewmh async ]> 2 | _ = require \highland 3 | 4 | wrap-display = (display) -> 5 | 6 | X = display.client 7 | root-window = display.screen.0.root 8 | 9 | do 10 | # `node-ewmh` currently (bug) expects these atoms to be defined in 11 | # `node-x11`'s atom cache and fails if they aren't. 12 | 13 | atom-names = <[ WM_PROTOCOLS WM_DELETE_WINDOW ]> 14 | e, atom-values <- async.map do 15 | atom-names 16 | (atom-name, cb) -> 17 | X.InternAtom do 18 | false # create it if it doesn't exist 19 | atom-name 20 | cb 21 | 22 | ewmh-client = new ewmh X, root-window 23 | 24 | window-data = {} 25 | 26 | interaction-stream = _! 27 | 28 | init-window-data = (id) !-> 29 | # Remember initial geometry 30 | e, geom <- X.Get-geometry id 31 | { x-pos : x, y-pos : y, width, height } = geom 32 | window-data[id] = geometry : { x, y, width, height } 33 | 34 | event-mask = x11.event-mask.StructureNotify 35 | .|. x11.event-mask.SubstructureNotify 36 | .|. x11.event-mask.SubstructureRedirect 37 | .|. x11.event-mask.FocusChange 38 | 39 | X 40 | # To prevent race conditions with node-ewmh also changing the root window 41 | # attributes, we grab the server for the duration of that change. 42 | ..Grab-server! 43 | # Subscribe to events 44 | ..Get-window-attributes root-window, (e, attrs) -> 45 | throw e if e 46 | event-mask .|.= attrs.event-mask 47 | X.Change-window-attributes root-window, { event-mask }, (err) -> 48 | # This callback isn't called on success; only on error. I think it's a 49 | # bug, but let's roll with it for now. 50 | if err.error is 10 51 | exit 1 'Error: another window manager already running.' 52 | ..Ungrab-server! 53 | 54 | # Pick up previously mapped windows. These must have been mapped by 55 | # another window manager instance previously. 56 | ..QueryTree root-window, (e, tree) -> 57 | tree.children.for-each init-window-data 58 | 59 | wrapped-window-cache = {} 60 | 61 | wrap-window = (id) -> 62 | 63 | if wrapped-window-cache[id] then return that 64 | 65 | map = (cb=->) -> X.Map-window id ; cb! 66 | 67 | get-geometry = (cb=->) -> 68 | if window-data[id]?geometry 69 | cb null that 70 | else 71 | e, geom <- X.Get-geometry id 72 | return cb e if e 73 | { x-pos : x, y-pos : y, width, height } = geom 74 | window-data[id] = 75 | geometry : { x, y, width, height } 76 | 77 | # Cache it 78 | if not window-data[id] then window-data[id] = {} 79 | window-data[id].geometry{ x-pos : x, y-pos : y, width, height } = geom 80 | 81 | cb null window-data[id].geometry 82 | 83 | get-attributes = (cb=->) -> X.Get-window-attributes id, cb 84 | 85 | move-to = (x, y, cb=->) -> 86 | interaction-stream.write { action : \move id, x, y } 87 | X.Move-window id, x, y ; cb! 88 | window-data[id].geometry 89 | ..x = x 90 | ..y = y 91 | 92 | resize-to = (x, y, cb=->) -> 93 | X.Resize-window id, x, y ; cb! 94 | window-data[id].geometry 95 | ..width = x 96 | ..height = y 97 | interaction-stream.write action : \resize id : id, width : x, height : y 98 | 99 | move-by = (dx, dy, cb=->) -> 100 | e, geom <- get-geometry! 101 | if e then return cb e 102 | { x, y } = geom 103 | new-x = x + dx 104 | new-y = y + dy 105 | X.Move-window id, new-x, new-y 106 | window-data[id].geometry 107 | ..x = new-x 108 | ..y = new-y 109 | interaction-stream.write action : \move id : id, x : new-x, y : new-y 110 | cb! 111 | 112 | resize-by = (dx, dy, cb=->) -> 113 | e, geom <- get-geometry! 114 | if e then return cb e 115 | { width, height } = geom 116 | if e then return cb e 117 | new-w = width + dx 118 | new-h = height + dy 119 | X.Resize-window id, new-w, new-h 120 | window-data[id].geometry 121 | ..width = new-w 122 | ..height = new-h 123 | interaction-stream.write do 124 | action : \resize id, width : new-w, height : new-h 125 | cb! 126 | 127 | set-input-focus = (cb=->) -> X.Set-input-focus id ; cb! 128 | 129 | raise-to-below = (sibling-window, cb=->) -> 130 | X.Configure-window id, { sibling : sibling-window?id, stack-mode : 1 } 131 | cb! 132 | 133 | close = (cb=->) -> ewmh-client.close_window id, true ; cb! 134 | 135 | kill = (cb=->) -> X.Kill-client id ; cb! 136 | 137 | subscribe-to = (event-names, cb=->) -> 138 | if typeof! event-names isnt \Array 139 | event-names := [ event-names ] 140 | 141 | event-mask = event-names.map (x11.event-mask.) .reduce (.|.), 0 142 | X.Change-window-attributes id, { event-mask } 143 | 144 | get-wm-class = (cb=->) -> 145 | e, prop <- X.Get-property 0 id, X.atoms.WM_CLASS, X.atoms.STRING, 0, 1000000 146 | return cb e if e 147 | switch prop.type 148 | | X.atoms.STRING => 149 | 150 | # Data format: 151 | # 152 | # prognameclassname 153 | # 154 | # where `` is a null-character. 155 | 156 | null-char = String.from-char-code 0 157 | strings = prop.data .to-string! .split null-char 158 | cb null program : strings.0, class : strings.1 159 | 160 | | 0 => cb null [ "" "" ] # No WM_CLASS set 161 | | _ => cb "Unexpected non-string WM_CLASS" 162 | 163 | return wrapped-window-cache[id] = { 164 | id, map, get-geometry, get-attributes, move-to, resize-to, move-by, 165 | resize-by, set-input-focus, raise-to-below, close, kill, subscribe-to, 166 | get-wm-class 167 | } 168 | 169 | wrap-event = -> 170 | type : it.name, window : wrap-window it.wid 171 | 172 | actions = 173 | ConfigureRequest : -> 174 | init-window-data it.wid 175 | ConfigureNotify : -> 176 | if window-data[it.wid] then that.geometry{ x, y, width, height } = it 177 | DestroyNotify : -> 178 | interaction-stream.write action : \destroy id : it.wid 179 | delete window-data[it.wid] 180 | delete wrapped-window-cache[it.wid] 181 | 182 | X.on \error -> console.error it 183 | 184 | root : wrap-window root-window 185 | interaction-stream : interaction-stream 186 | event-stream : _ \event X 187 | .map -> 188 | if actions[it.name] then that it 189 | return it 190 | #.filter (.name in interesting-events) 191 | .map wrap-event 192 | all-windows : (cb) -> 193 | X.QueryTree root-window, (e, tree) -> 194 | if e then return cb e 195 | cb null tree.children.map wrap-window 196 | window-under-pointer : (cb) -> 197 | e, res <- X.Query-pointer root-window 198 | if e then return cb e 199 | cb null wrap-window res.child 200 | 201 | module.exports = (cb) -> 202 | e, display <- x11.create-client! 203 | cb e, wrap-display display 204 | -------------------------------------------------------------------------------- /index.ls: -------------------------------------------------------------------------------- 1 | require! <[ fs net split async ]> 2 | wm-kit = require \../wm-kit.js 3 | { words } = require \prelude-ls 4 | { spawn } = require \child_process 5 | { mkfifo-sync } = require \mkfifo 6 | _ = require \highland 7 | 8 | argv = require \yargs .argv 9 | 10 | verbose-log = if argv.verbose then console.log else -> # no-op 11 | 12 | e, wm <- wm-kit! 13 | throw e if e 14 | 15 | windows = [] 16 | focus = wm.root 17 | 18 | <- fs.unlink "/tmp/wmstate.sock" 19 | state-output = do 20 | clients = {} 21 | server = net.create-server! 22 | ..listen "/tmp/wmstate.sock" 23 | ..on \error -> console.error "Socket error: #it" 24 | ..on \connection (stream) -> 25 | console.log "New connection" 26 | 27 | # The file descriptors of socket connections are unique, so that's 28 | # something to use as a UUID key. 29 | stream-id = stream._handle.fd 30 | clients[stream-id] = stream 31 | stream 32 | ..on \close -> 33 | stream.end! 34 | delete clients[stream-id] 35 | ..on \error -> 36 | stream.end! 37 | delete clients[stream-id] 38 | 39 | # Send initial state 40 | windows.for-each (window) -> 41 | e, { x, y, width, height } <- window.get-geometry! 42 | stream.write JSON.stringify { 43 | action : \existing-add 44 | id : Number window.id 45 | x, y, width, height 46 | } 47 | stream.write \\n 48 | -> 49 | console.log "loggin" it 50 | for id, stream of clients 51 | stream 52 | ..write JSON.stringify it 53 | ..write \\n 54 | 55 | focus-on = (window) -> 56 | focus := window 57 | ..set-input-focus! 58 | state-output action : \focus id : window.id 59 | 60 | on-top-windows = [] 61 | 62 | consider-managing = (window) -> 63 | e, attr <- window.get-attributes! 64 | throw e if e 65 | 66 | if not attr.override-redirect # don't pick up popups 67 | 68 | e, wm-class <- window.get-wm-class! 69 | 70 | if wm-class.class is \Hudkit 71 | window.raise-to-below on-top-windows.0 72 | on-top-windows.push window 73 | 74 | else 75 | windows.push window 76 | 77 | window.subscribe-to \EnterWindow 78 | 79 | e, { x, y, width, height } <- window.get-geometry! 80 | throw e if e 81 | state-output { 82 | action : \add 83 | id : window.id 84 | x, y, width, height 85 | } 86 | 87 | window.raise-to-below on-top-windows.0 88 | window.set-input-focus! 89 | focus-on window 90 | 91 | # Pick up existing mapped windows and manage them if necessary 92 | wm .all-windows (e, wins) -> 93 | throw e if e 94 | w <- wins.for-each 95 | e, attr <- w.get-attributes! 96 | throw e if e 97 | if attr.map-state then consider-managing w 98 | 99 | _ wm.interaction-stream 100 | #.filter -> windows.some (.id == it) 101 | .each state-output 102 | 103 | wm.event-stream .on \data ({ type, window }) -> 104 | switch type 105 | | \MapRequest => 106 | e <- window.map! 107 | throw e if e 108 | consider-managing window 109 | | \ConfigureRequest => # ignore 110 | # action.resize ev.wid, ev.width, ev.height 111 | | \DestroyNotify => fallthrough 112 | | \UnmapNotify => 113 | for i til windows.length 114 | if windows[i].id is window.id 115 | windows.splice i, 1 116 | break 117 | if focus.id is window.id 118 | verbose-log "focusing root" 119 | focus-on wm.root 120 | | \EnterNotify => focus-on window 121 | | \ClientMessage => # nothing 122 | 123 | commands = do 124 | 125 | drag = 126 | target : null 127 | start : x : 0 y : 0 128 | drag-update = (x, y, target) -> 129 | drag 130 | ..target = target if target 131 | ..start 132 | ..x = x 133 | ..y = y 134 | drag-reset = -> 135 | drag 136 | ..target = null 137 | ..start 138 | ..x = null 139 | ..y = null 140 | 141 | commands = {} 142 | 143 | specify = (name, ...types, action) -> 144 | commands[name] = (args) -> 145 | if args.length isnt types.length 146 | return console.error "Invalid number of arguments to command #name" 147 | args-converted = [] 148 | for a,i in args 149 | try 150 | converted-arg = global[types[i]] a 151 | args-converted.push converted-arg 152 | catch e 153 | console.error "Could not interpret #a as #{types[i]}" 154 | action.apply null args-converted 155 | 156 | specify \resize \Number \Number -> 157 | return if focus.id is root.id 158 | focus.resize-by &0, &1 159 | specify \move \Number \Number -> 160 | return if focus.id is root.id 161 | focus.move-by &0, &1 162 | specify \move-all \Number \Number (x, y) -> 163 | return unless windows.length 164 | async.each do 165 | windows 166 | (w, cb) -> w.move-by x, y, cb 167 | -> # We don't care when it finishes, or about errors. 168 | specify \pointer-resize \Number \Number (x, y) -> 169 | return if focus.id is root.id 170 | if drag.target is null then drag-update x, y, focus 171 | delta-x = x - drag.start.x 172 | delta-y = y - drag.start.y 173 | drag-update x, y 174 | drag.target.resize-by delta-x, delta-y 175 | specify \pointer-move \Number \Number (x, y) -> 176 | return if focus.id is root.id 177 | if drag.target is null then drag-update x, y, focus 178 | delta-x = x - drag.start.x 179 | delta-y = y - drag.start.y 180 | drag-update x, y 181 | drag.target.move-by delta-x, delta-y 182 | specify \pointer-move-all \Number \Number (x, y) -> 183 | return if focus.id is root.id 184 | if drag.target is null then drag-update x, y, focus 185 | delta-x = (x - drag.start.x) * 2 186 | delta-y = (y - drag.start.y) * 2 187 | verbose-log "Moving all by #delta-x,#delta-y" 188 | drag-update x, y 189 | async.each do 190 | windows 191 | (w, cb) -> w.move-by delta-x, delta-y, cb 192 | -> # We don't care when it finishes, or about errors. 193 | specify \reset -> 194 | verbose-log "drag reset" 195 | drag-reset! 196 | specify \raise -> focus.raise-to-below on-top-windows.0 197 | specify \pointer-raise -> # Find and raise the window under the pointer 198 | e, w <- wm.window-under-pointer! 199 | throw e if e 200 | if w then w.raise-to-below on-top-windows.0 201 | specify \kill -> focus.kill! 202 | specify \destroy -> focus.close! 203 | specify \exit -> process.exit! 204 | 205 | commands # return 206 | 207 | run-command = (line) -> 208 | args = line |> words 209 | return unless args.length 210 | command-name = args.shift! 211 | if commands[command-name] 212 | that args 213 | else console.error "No such command: #command-name" 214 | 215 | input-stream = do 216 | 217 | mkfifo-stream = (path) -> 218 | if fs.exists-sync path then fs.unlink-sync path 219 | mkfifo-sync path, 8~600 220 | (spawn \tail [ \-F path ]).stdout 221 | 222 | switch argv.command-file 223 | | \- => process.stdin 224 | | true => fallthrough 225 | | undefined => mkfifo-stream "/tmp/basedwm#{process.env.DISPLAY}-cmd.fifo" 226 | | otherwise => mkfifo-stream that 227 | 228 | input-stream .pipe split \\n .on \data (line) -> run-command line 229 | --------------------------------------------------------------------------------