eigenclass logo
MAIN  Index  Search  Changes  PageRank  Login

Scripting the wmii-3 window manager with Ruby

update.png UPDATE Much improved script released.


It's been over one week since the release of wmii-3, the dynamic window manager thats boasts

Wimp (Windows, Icons, Menus and Pointing device) is dead!

It took me one day to grow accustomed to the new tag-based workspaces, and a couple to stabilize my setup.

wmii ships with a few shell scripts that control the behavior of the WM: key bindings, menu bar, default options... I was more than willing to rewrite them in Ruby in order to get rid of that echo, sed, awk, etc. mess. In the process I abstracted things a little bit, making it easier to handle the events reported by the WM.

I can now define basic keybindings as follows:

  on_key("#{MODKEY}-#{LEFT}"){ write "/view/ctl", "select prev" }
  on_key("#{MODKEY}-#{RIGHT}"){ write "/view/ctl", "select next" }
  on_key("#{MODKEY}-#{DOWN}"){ write "/view/sel/ctl", "select next" }
  on_key("#{MODKEY}-#{UP}"){ write "/view/sel/ctl", "select prev" }

Other events are easily defined too. This is my "volume applet":

  on_barclick(volume_name, MOUSE_SCROLL_UP){ update_volume[+1] }
  on_barclick(volume_name, MOUSE_SCROLL_DOWN){ update_volume[-1] }

The good thing about using Ruby is that adding extra logic isn't a PITA, as it is in shell script, at least for me. The following snippet allows me to retag the current client partially, replacing only the numerical tag and leaving the other ones untouched:

  (0..9).each do |key|
    on_key("#{MODKEY}-Shift-#{key}") do
      oldtags = read("/view/sel/sel/tags").split(/\+/).reject{|x| /^\d+$/ =~ x}
      write "/view/sel/sel/tags", (oldtags + [key]).join("+")
    end
  end

Finally, here's something I'd been wanting for some time but never got to implement before: a global keybinding for quick dictionary lookup without ever using the pointing device.

  dict_name = "80_dict"
  setup_bar(dict_name, WMII_NORMCOLORS, "DICT")
  
  dict_ask_and_define = lambda do
    fork do
      phrase = `wmiimenu < /dev/null`
      system "dcop kdict KDictIface definePhrase '#{phrase}'"
      write "/ctl", "view dict"
    end
  end
  on_key("#{MODKEY}-Control-d"){ dict_ask_and_define.call }
  on_barclick(dict_name, MOUSE_BUTTON_LEFT){ dict_ask_and_define.call }
  on_barclick(dict_name, MOUSE_BUTTON_RIGHT){ write "/ctl", "view dict" }

I guess this makes me a weirdo (maybe even more so amongst Rubyists, which would often kill for a Mac --- their third or fourth one) but I much prefer this to Exposé+Dock+Dashboard and all that eye candy (not that I've used it that much, but more than it took me to like wmii --- maybe the learning curve is too steep? :-P).

Code

The script is quite long --- much longer than the original shell script (of course, it also does much more). But I find it's way easier to extend, especially if I want stateful event handling and non-trivial logic.

I used to have a Ruby extension to use the IXP IPC, but I haven't recompiled it against the latest libixp and the wmiir tool does the work nicely (if slowly) for now:

#!/usr/bin/env ruby

require 'logger'

logfile = File.open(File.join(ENV["HOME"], ".wmii-3", "wmiirc.log"), "a")
logfile.sync = true
STDOUT.reopen(logfile)
STDERR.reopen(logfile)
LOGGER = Logger.new(STDERR)
LOGGER.info "wmiirc init"

WMIIRC_HOME = ENV["HOME"] + "/.wmii-3"

class EventReactor
  class Handler
    attr_reader :type

    def initialize(type, &block)
      @type = type
      @block = block
    end

    def call(*a)
      @block.call(*a)
    end
  end

  class KeyHandler < Handler
    attr_reader :key

    def initialize(key, &block)
      @key = key
      super("Key", &block)
    end
  end

  def initialize(&block)
    @event_source = open("|wmiir read /event")
    @procs = {"BarClick" => [], "ClientClick" => [],
              "ClientFocus" => [], "CreateClient" => [], 
              "Key" => Hash.new{|h,k| h[k] = []}}
    @children = [@event_source.pid]
    # reset key bindings
    IO.popen("wmiir write /def/keys", "w"){|io| io.puts }
    instance_eval(&block) if block_given?
  end

  def register(type, param1 = nil, param2 = nil, &block)
    raise "Unknown type" unless @procs.has_key?(type)
    case param1
    when nil
      handler = Handler.new(type, &block)
    else
      handler = Handler.new(type) do |*args|
        if param1 === args[0] && (!param2 || param2 === args[1])
          block.call(*args)
        end
      end
    end
    @procs[type] << handler

    handler
  end

  def unregister(handler)
    case handler
    when KeyHandler
      @procs["Key"][handler.key]
    else
      @procs[handler.type].delete handler
    end
  end

  def main_loop
    # register key bindings
    IO.popen("wmiir write /def/keys", "w"){|io| io.puts @procs["Key"].keys }
    # wait for events
    while line = @event_source.gets
      LOGGER.debug "Got #{line.inspect} from #{@event_source.pid}"
      case line
      when /(BarClick|ClientClick)\s+(\S+)\s+(\S+)$/
        @procs[$1].each{|x| x.call($2, $3.to_i)}
      when /(ClientFocus|CreateClient)\s+(\S+)/
        @procs[$1].each{|x| x.call($2.to_i)}
      when /Key (\S+)$/
        @procs["Key"][$1].each{|x| x.call(x)} if @procs["Key"].has_key?($1)
      when /Bye/
        break
      end
    end
  end

  # not worth meta-programming 
  def on_barclick(name = nil, button = nil, &b)
    register("BarClick", name, button, &b)
  end

  def on_clientclick(client = nil, button = nil, &b)
    register("ClientClick", client, button, &b)
  end

  def on_clientfocus(client = nil, &b); register("ClientFocus", client, &b) end
  def on_createclient(client = nil, &b); register("CreateClient", client, &b) end

  def on_key(key, &block)
    handler = KeyHandler.new("Key", &block)
    @procs["Key"][key] << handler

    handler
  end

  def clean_fork(&b)
    @children << fork(&b)
    LOGGER.info "clean_fork(): #{@children.last}"
  end

  def cleanup
    LOGGER.info "This is #{Process.pid}, killing #{@children.inspect}"
    @children.each do |pid|
      begin
        Process.kill("TERM", pid)
      rescue Exception
      end
    end
  end
end

module WMII
  MOUSE_BUTTON_LEFT = 1
  MOUSE_BUTTON_MIDDLE = 2
  MOUSE_BUTTON_RIGHT = 3
  MOUSE_SCROLL_UP = 4
  MOUSE_SCROLL_DOWN = 5
  def write(file, contents)
    IO.popen("wmiir write #{file}", "w"){|io| io.print contents.to_s }
    true
  rescue Errno::EPIPE
    return false
  end
  
  def read(file)
    `wmiir read #{file}`
  end

  def remove(file)
    system("wmiir remove #{file}")
  end

  def create(file)
    system("wmiir create #{file}")
  end

  def setup_bar(name, colors, data = "")
    remove "/bar/#{name}"
    create "/bar/#{name}"
    write "/bar/#{name}/colors", colors
    write "/bar/#{name}/data", data.chomp
  end

  def view(viewname)
    write "/ctl", "view #{viewname.to_s.chomp}"
  end

  def wmiimenu(options, &block)
    fork do 
      chosen = IO.popen("wmiimenu", "r+") do |f|
        f.puts options
        f.close_write
        f.read
      end.chomp
      yield chosen if block_given?
      LOGGER.debug "wmiimenu(#{chosen.inspect}) finished"
    end
  end

  def program_list
    @__program_list_last_update ||= Time.at(0)
    @__program_list ||= []
    path_glob = ENV["PATH"].gsub(/,/, '\\,').tr(":",",")
    return @__program_list if Time.new - @__program_list_last_update < 3600
    @__program_list_last_update = Time.new
    @__program_list = Dir.glob("{#{path_glob}}/*").select do |fname|
      File.file?(fname) && File.executable?(fname)
    end.map{|fname| File.basename(fname)}.sort.uniq
  end

  def action_list
    Dir["#{ENV["HOME"]}/.wmii-3/*"].select do |f|
      File.file?(f) && File.executable?(f)
    end.map{|f| File.basename(f)}.sort.uniq
  end

  def condition(&block)
    c = lambda(&block)
    def c.===(*x); call(*x) end
    c
  end
end

include WMII

LOGGER.info "loading configuration"
# {{{ ======== CONFIGURATION BEGINS HERE ==============

MODKEY = "Mod1"
UP     = "k"
DOWN   = "j"
LEFT   = "h"
RIGHT  = "l"

WMII_FONT        =  'fixed'
#WMII_SELCOLORS   = '#ffffff #285577 #4c7899'
#WMII_NORMCOLORS  = '#222222 #eeeeee #666666'

# dark background
#WMII_NORMCOLORS ='#e0e0e0 #0a0a0a #202020'

#WMII_FONT='-adobe-helvetica-bold-r-normal-*-12-120-75-75-p-70-iso8859-1'
#WMII_SELCOLORS='#ffffff #dd0fdd #6600ff'
#WMII_NORMCOLORS='#ffddff #000000 #dd0fdd'
WMII_SELCOLORS='#ffffff #4682b4 #cccccc'
WMII_NORMCOLORS='#ffffff #36648b #909090'

XMESSAGEBOX      = "xmessage -center -buttons quit:0 -default quit -file -"

write "/event", "Bye\n"


# {{{ EventReactor
reactor = EventReactor.new do |e|

# {{{ WM CONFIGURATION
  write "/def/border", 1
  write "/def/font", WMII_FONT
  write "/def/selcolors", WMII_SELCOLORS
  write "/def/normcolors", WMII_NORMCOLORS
  write "/def/colmode", "default"
  write "/def/colwidth", 0

  write "/def/rules", <<EOF
/Kdict.*/ -> dict
/XMMS.*/ -> ~
/Gimp.*/ -> ~
/MPlayer.*/ -> ~
/XForm.*/ -> ~
/.*/ -> !
/.*/ -> 1
EOF

# {{{ Signal we're about to start to the previous wmiirc process, or wait
# until the IXP connection is established
  loop{ write "/event", "Starting" and break }
# {{{ MOD-Control-y prefix to create in new view
  e.on_key "#{MODKEY}-Control-y" do
    handler = on_createclient do |cid|
      last_tag = read("/tags").select{|x| /^[0-9]+$/ =~ x}.sort.last
      new_tag = last_tag.to_i + 1
      old_tags = read("/client/#{cid}/tags").split(/\+/).reject{|x| /^[0-9]+/ =~ x}
      write("/client/#{cid}/tags", (old_tags + [new_tag]).join("+"))
      e.unregister handler
      write("/ctl", "view #{new_tag}")
    end
    system("wmiisetsid `wmiimenu < /tmp/.wmiimenu.$USER.proglist` &") 
  end

# {{{ Volume control
  volume_name = "99_volume"
  setup_bar(volume_name, WMII_NORMCOLORS)
  update_volume = lambda do |increment|
    sign = increment < 0 ? "-" : "+"
    volume = `amixer set PCM,0 #{increment.abs}#{sign}`[/\[(\d+%)\]/]
    write("/bar/#{volume_name}/data", "VOL #{volume}")
  end
  Thread.new{ loop { update_volume[0]; sleep 10 } }
  
  on_barclick(volume_name, MOUSE_SCROLL_UP){ update_volume[+1] }
  on_barclick(volume_name, MOUSE_SCROLL_DOWN){ update_volume[-1] }

# {{{ Dictionary

  dict_name = "80_dict"
  setup_bar(dict_name, WMII_NORMCOLORS, "DICT")
  
  dict_ask_and_define = lambda do
    fork do
      phrase = `wmiimenu < /dev/null`
      system "dcop kdict KDictIface definePhrase '#{phrase}'"
      write "/ctl", "view dict"
    end
  end
  on_key("#{MODKEY}-Control-d"){ dict_ask_and_define.call }
  on_barclick(dict_name, MOUSE_BUTTON_LEFT){ dict_ask_and_define.call }
  on_barclick(dict_name, MOUSE_BUTTON_RIGHT){ write "/ctl", "view dict" }

# {{{ Status bar
  status_name = "00_status"
  setup_bar(status_name, WMII_NORMCOLORS, "STATUS BAR --- init")
  clean_fork do
    sleep 2
    loop do
      currload = `uptime`.sub(/.*: /,"").gsub(/,/,"")
      text= "#{Time.new.strftime("%d/%m/%Y %X %Z")} #{currload}"
      write("/bar/#{status_name}/data", text.chomp)
      sleep 1
    end
  end

  on_barclick(status_name) do |name, button|
    views = read("/tags").map{|x| x.chomp}
    current = views.index read("/view/name")
    case button.to_i
    when MOUSE_BUTTON_LEFT: system "tzwatch | #{XMESSAGEBOX} &"
    when MOUSE_BUTTON_MIDDLE: system "x-terminal-emulator -C top &"
    when MOUSE_BUTTON_RIGHT: system "ncal -y | #{XMESSAGEBOX} &"
    when MOUSE_SCROLL_UP: write "/ctl", "view #{views[current-1] || views[-1]}"
    when MOUSE_SCROLL_DOWN: write "/ctl", "view #{views[current+1] || views[0]}"
    end
  end

# {{{ Click on view bars
  all_but_status = /^(?!#{Regexp.escape(status_name)})/
  on_barclick(all_but_status, MOUSE_BUTTON_LEFT){|name,| view name}
  on_barclick(all_but_status, MOUSE_BUTTON_RIGHT){|name,| view name}

# {{{ Tag all konqueror instances as 'konqueror'
  e.on_createclient(condition{|c| /^Konqueror:/ =~ read("/client/#{c}/class")}) do |cid|
    write("/client/#{cid}/tags", 
          (read("/client/#{cid}/tags").split(/\+/) + ["konqueror"]).join("+"))
  end

# {{{ Key bindings
  on_key("#{MODKEY}-#{LEFT}"){ write "/view/ctl", "select prev" }
  on_key("#{MODKEY}-#{RIGHT}"){ write "/view/ctl", "select next" }
  on_key("#{MODKEY}-#{DOWN}"){ write "/view/sel/ctl", "select next" }
  on_key("#{MODKEY}-#{UP}"){ write "/view/sel/ctl", "select prev" }
  on_key("#{MODKEY}-space"){ write "/view/ctl", "select toggle" }
  on_key("#{MODKEY}-d"){ write "/view/sel/mode", "default" }
  on_key("#{MODKEY}-s"){ write "/view/sel/mode", "stack" }
  on_key("#{MODKEY}-m"){ write "/view/sel/mode", "max" }
  on_key("#{MODKEY}-f"){ write "/view/0/sel/geom", "0 0 east south" }
  on_key("#{MODKEY}-a") do
    internal_actions = {
      "browser" => lambda do
        selection = `wmiipsel`.strip
        case browser = ENV["BROWSER"]
        when nil: system "/etc/alternatives/x-www-browser '#{selection}' &"
        else system "#{browser} '#{selection}' &"
        end
      end,
      "google" => lambda do
        require 'cgi'
        selection = CGI.escape(%!"#{`wmiipsel`.strip}"!)
        url = "http://www.google.com/search?q=#{selection}"
        case browser = ENV["BROWSER"]
        when nil: system "/etc/alternatives/x-www-browser '#{url}' &"
        else system "#{browser} '#{url}' &"
        end
      end
    }
    wmiimenu((action_list + internal_actions.keys).sort) do |choice|
      if internal_actions[choice]
        internal_actions[choice].call
      else
        system("$HOME/.wmii-3/#{choice} &") if /^\s*$/ !~ choice
      end
    end
  end
  on_key("#{MODKEY}-p") do
    wmiimenu(program_list){|prog| system("#{prog} &") if /^\s*$/ !~ prog }
  end
  on_key("#{MODKEY}-t") do
    wmiimenu(read("/tags")){|new_view| view(new_view)}
  end
  (0..9).each{|key| on_key("#{MODKEY}-#{key}"){ view(key) } }
  on_key("#{MODKEY}-Return"){ system "x-terminal-emulator &" }
  on_key("#{MODKEY}-Shift-#{LEFT}"){ write "/view/sel/sel/ctl", "sendto prev" }
  on_key("#{MODKEY}-Shift-#{RIGHT}"){ write "/view/sel/sel/ctl", "sendto next" }
  on_key("#{MODKEY}-Shift-#{DOWN}"){ write "/view/sel/sel/ctl", "swap down" }
  on_key("#{MODKEY}-Shift-#{UP}"){ write "/view/sel/sel/ctl", "swap up" }
  on_key("#{MODKEY}-Shift-space"){ write "/view/sel/sel/ctl", "sendto toggle" }
  on_key("#{MODKEY}-Shift-c"){ write "/view/sel/sel/ctl", "kill" }
  on_key("#{MODKEY}-Shift-t") do
    wmiimenu(read("/tags")){|new_tags| write "/view/sel/sel/tags", new_tags }
  end
  (0..9).each do |key|
    on_key("#{MODKEY}-Shift-#{key}") do
      oldtags = read("/view/sel/sel/tags").split(/\+/).reject{|x| /^\d+$/ =~ x}
      write "/view/sel/sel/tags", (oldtags + [key]).join("+")
    end
  end
  on_key("#{MODKEY}-Control-#{LEFT}"){ write "/view/sel/sel/ctl", "swap prev" }
  on_key("#{MODKEY}-Control-#{RIGHT}"){ write "/view/sel/sel/ctl", "swap next" }

  get_view_data = lambda do
    views = read("/tags").map{|x| x.chomp}
    current = views.index read("/view/name")
    [views, current]
  end
  on_key("#{MODKEY}-Control-#{DOWN}") do 
    views, current = get_view_data.call
    view(views[current-1] || views[-1])
  end
  on_key("#{MODKEY}-Control-#{UP}") do
    views, current = get_view_data.call
    view(views[current+1] || views[0])
  end
end

# {{{ ==== main loop
system "xsetroot -solid '#333333'" 
system "x-terminal-emulator &" 
sleep 0.2
system "kdict &" 

LOGGER.info "Executing main loop..."
reactor.main_loop
write "/event", "Bye\n"
reactor.cleanup
LOGGER.info "NORMAL EXIT"


_why bindings - Tom (2006-06-18 (Sun) 13:46:21)

mfp: Actually, yes: http://redhanded.hobix.com/inspect/aFewWmii3Hacks.html

And thanks from me for this code; as it's an excellent starting point for Ruby+WMII-3!

mfp 2006-06-18 (Sun) 17:23:59

yup, saw that

Glad you liked it... there's some more coming :)

I have evolved my wmiirc (I'd added several bindings since I posted this; I saw some of them reimplemented on redhanded --- we're all walking similar paths in the usability space :) and will probably release something tomorrow. I also have an IXP extension built on top of wmii's libixp, but there are still a couple pending issues.


so fantastic - why (2006-06-03 (Sat) 19:40:53)

thanks, mfp. you have me hooked. i've been playing with this all day.

mfp 2006-06-05 (Mon) 15:43:07

Hey _why, came up with any interesting binding? (<- high expectations)


Last modified:2006/05/29 05:10:39
Keyword(s):[ruby] [blog] [frontpage] [wmii] [wm] [script] [wmiirc]
References:[Ruby] [Automagic (generalizable) working set discovery, improved ruby-wmii WM scripting 0.2.1]