New widget API + format strings instead of just fields
[amazing.git] / amazing.rb
blobab23724a61526017ac9eb34fcf8ae51a3a6e3910
1 #!/usr/bin/env ruby
4 # = amazing 
6 # an amazing widget manager for an awesome window manager
8 #   Usage: amazing [options]
9 #      -c, --config FILE                Configuration file (~/.amazing.yml)
10 #      -s, --screen ID                  Screen ID (0)
11 #      -l, --log-level LEVEL            Severity threshold (info)
12 #      -i, --include SCRIPT             Include a widgets script
13 #      -u, --update WIDGET              Update a widget and exit
14 #      -w, --list-widgets               List available widgets
15 #      -h, --help                       You're looking at it
17 # == Widgets
19 # * Battery: Remaining battery power in percentage
20 # * Maildir: Mail count in maildirs
21 # * ALSA: Various data for the ALSA mixer
22 # * Raggle: Unread posts in raggle
23 # * Memory: Various memory related data
24 # * Clock: Displays date and time
26 # == Configuration
28 #   include:
29 #     - list
30 #     - of
31 #     - scripts
32 #   screens:
33 #     - list
34 #     - of
35 #     - screens
36 #   widgets:
37 #     identifier:
38 #       type: WidgetName
39 #       every: seconds
40 #       format: ruby code
41 #       options:
42 #         widget: foo
43 #         specific: bar
45 # == Example
46
47 #   widgets:
48 #     pb_bat:
49 #       type: Battery
50 #       every: 10
51 #     tb_time:
52 #       type: Clock
53 #       every: 1
54 #       options:
55 #         time_format: %T
56 #     tb_mail:
57 #       type: Maildir
58 #       options:
59 #         directories:
60 #           - Mail/**/new
61 #           - Mail/inbox/cur
63 # In this example tb_mail doesn't have an "every" setting and is instead
64 # updated manually with <tt>amazing -u tb_mail</tt>, perhaps in cron after fetching
65 # new mail via fdm, getmail, fetchmail or similar. A good idea is also to
66 # update after closing your MUA such as Mutt which could be done with
67 # shell functions, example:
69 #   mutt() {
70 #     mutt $*
71 #     amazing -u tb_mail
72 #   }
74 # == Writing widgets
76 # Widgets inherit from Widget, serves data via instance methods, signalizes
77 # errors by raising a WidgetError and processes widget options via @options.
78 # The init method is used instead of initialize. Here's an example:
80 #   class Clock < Widget
81 #     description "Displays date and time"
82 #     option :time_format, "Time format as described in DATE(1)", "%R"
83 #     field :time, "Formatted time"
84 #     default "@time"
85
86 #     init do
87 #       @time = Time.now.strftime(@time_format)
88 #       raise WidgetError, "An error occured!" if some_error?
89 #     end
90 #   end
92 # The ProcFile class can be used for parsing /proc files:
94 #   cpuinfo = ProcFile.new("cpuinfo")
95 #   cpuinfo[1]["model name"]
96 #   #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
98 # == Todo
100 # * Maybe auto-include scripts from ~/.amazing/something
101 # * Self-documenting widgets (list fields and options) (done in widgets)
102 # * Some widgets need to support multiple data sources
103 # * Some way to do alerts, e.g. "blinking"
104 # * Make widget configuration screen specific
105 # * Support widgets with multiple bars and graphs (maybe wait for 2.3)
106 # * More widgets, duh
108 # == Copying
110 # Copyright (C) 2008 Dag Odenhall <dag.odenhall@gmail.com>
111 # Licensed under the Academic Free License version 3.0
112 # http://www.rosenlaw.com/AFL3.0.htm
115 require 'optparse'
116 require 'logger'
117 require 'yaml'
118 require 'timeout'
119 require 'thread'
120 require 'pstore'
122 module Amazing
124   module X11
126     # Raised by DisplayName#new if called with empty argument, or without
127     # argument and ENV["DISPLAY"] is empty.
128     class EmptyDisplayName < ArgumentError
129     end
131     # Raised by DisplayName#new if format of argument or ENV["DISPLAY"] is
132     # invalid.
133     class InvalidDisplayName < ArgumentError
134     end
136     # Parse an X11 display name
137     #
138     #   display = DisplayName.new("hostname:displaynumber.screennumber")
139     #   display.hostname #=> "hostname"
140     #   display.display  #=> "displaynumber"
141     #   display.screen   #=> "screennumber"
142     #
143     # Without arguments, reads ENV["DISPLAY"]. With empty argument or
144     # DISPLAY environment, raises EmptyDisplayName. With invalid display name
145     # format, raises InvalidDisplayName. 
146     class DisplayName
147       attr_reader :hostname, :display, :screen
149       def initialize(display_name=ENV["DISPLAY"])
150         raise EmptyDisplayName, "No display name supplied" if ["", nil].include? display_name
151         @hostname, @display, @screen = display_name.scan(/^(.*):(\d+)(?:\.(\d+))?$/)[0]
152         raise InvalidDisplayName, "Invalid display name" if @display.nil?
153         @hostname = "localhost" if @hostname.empty?
154         @screen = "0" unless @screen
155       end
156     end
157   end
159   # Communicate with awesome
160   #
161   #   awesome = Awesome.new
162   #   awesome.widget_tell(widget_id, "Hello, world")
163   #   awesome = Awesome.new(1)
164   #   awesome.tag_view(3)
165   #   Awesome.new.client_zoom
166   class Awesome
167     attr_accessor :screen, :display
169     def initialize(screen=0, display=0)
170       @screen = screen.to_i
171       @display = display
172     end
174     def method_missing(method, *args)
175       IO.popen("env DISPLAY=#{display} awesome-client", IO::WRONLY) do |ac|
176         ac.puts "#@screen #{method} #{args.join(' ')}"
177       end
178     end
179   end
181   # Parse a /proc file
182   #
183   #   cpuinfo = ProcFile.new("cpuinfo")
184   #   cpuinfo[1]["model name"]
185   #   #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
186   class ProcFile
187     include Enumerable
189     def initialize(file)
190       file = "/proc/#{file}" if file[0] != ?/
191       @list = [{}]
192       File.readlines(file).each do |line|
193         if sep = line.index(":")
194           @list[-1][line[0..sep-1].strip] = line[sep+1..-1].strip
195         else
196           @list << {}
197         end
198       end
199       @list.pop if @list[-1].empty?
200     end
202     def each
203       @list.each do |section|
204         yield section
205       end
206     end
208     def [](section)
209       @list[section]
210     end
211   end
213   # Raised by widgets, and is then rescued and logged
214   class WidgetError < Exception
215   end
217   # Parent class for widget construction, example:
218   #
219   #   class Clock < Widget
220   #     description "Displays date and time"
221   #     option :time_format, "Time format as described in DATE(1)", "%R"
222   #     field :time, "Formatted time"
223   #     default "@time"
224   # 
225   #     init do
226   #       @time = Time.now.strftime(@time_format)
227   #       raise WidgetError, "An error occured!" if some_error?
228   #     end
229   #   end
230   class Widget
231     def initialize(identifier=nil, format=nil, opts={})
232       @identifier, @format = identifier, format
233       self.class.options.each do |key, value|
234         value = opts[key.to_s] || value[:default]
235         instance_variable_set "@#{key}".to_sym, value
236       end
237       self.class.fields.each do |key, value|
238         instance_variable_set "@#{key}".to_sym, value[:default]
239       end
240       instance_eval(&self.class.init) if self.class.init
241     end
243     def self.description(description=nil)
244       if description
245         @description = description
246       else
247         @description
248       end
249     end
251     def self.option(name, description=nil, default=nil)
252       @options ||= {}
253       @options[name] = {:description => description, :default => default}
254     end
256     def self.options
257       @options || {}
258     end
260     def self.field(name, description=nil, default=nil)
261       @fields ||= {}
262       @fields[name] = {:description => description, :default => default}
263     end
265     def self.fields
266       @fields || {}
267     end
269     def self.default(format=nil, &block)
270       if format
271         @default = format
272       elsif block
273         @default = block
274       else
275         @default
276       end
277     end
279     def self.init(&block)
280       if block
281         @init = block
282       else
283         @init
284       end
285     end
287     def formatize
288       if @format
289         instance_eval(@format)
290       else
291         case self.class.default
292         when Proc
293           instance_eval(&self.class.default)
294         when String
295           instance_eval(self.class.default)
296         end
297       end
298     end
299   end
301   module Widgets
302     class ALSA < Widget
303       description "Various data for the ALSA mixer"
304       option :mixer, "ALSA mixer name", "Master"
305       field :volume, "Volume in percentage", 0
306       default "@volume"
308       init do
309         IO.popen("amixer get #@mixer", IO::RDONLY) do |am|
310           out = am.read
311           volumes = out.scan(/\[(\d+)%\]/).flatten
312           volumes.each {|vol| @volume += vol.to_i }
313           @volume = @volume / volumes.size
314         end
315       end
316     end
318     class Battery < Widget
319       description "Remaining battery power in percentage"
320       option :battery, "Battery number", 1
321       field :percentage, "Power percentage", 0
322       default "@percentage"
324       init do
325         batinfo = ProcFile.new("acpi/battery/BAT#@battery/info")[0]
326         batstate = ProcFile.new("acpi/battery/BAT#@battery/state")[0]
327         remaining = batstate["remaining capacity"].to_i
328         lastfull = batinfo["last full capacity"].to_i
329         @percentage = (remaining * 100) / lastfull.to_f
330       end
331     end
333     class Clock < Widget
334       description "Displays date and time"
335       option :time_format, "Time format as described in DATE(1)", "%R"
336       field :time, "Formatted time"
337       default "@time"
339       init do
340         @time = Time.now.strftime(@time_format)
341       end
342     end
344     class Maildir < Widget
345       description "Mail count in maildirs"
346       option :directories, "Globs of maildirs" # TODO: does a default make sense?
347       field :count, "Ammount of mail in searched directories", 0
348       default "@count"
350       init do
351         raise WidgetError, "No directories configured" unless @directories
352         @directories.each do |glob|
353           glob = "#{ENV["HOME"]}/#{glob}" if glob[0] != ?/
354           @count += Dir["#{glob}/*"].size
355         end
356       end
357     end
359     class Memory < Widget
360       description "Various memory related data"
361       field :total, "Total kilobytes of memory", 0
362       field :free, "Free kilobytes of memory", 0
363       field :buffers, nil, 0 # TODO: description
364       field :cached, nil, 0 # TODO: description
365       field :usage, "Percentage of used memory", 0
366       default "@usage"
368       init do
369         meminfo = ProcFile.new("meminfo")[0]
370         @total = meminfo["MemTotal"].to_i
371         @free = meminfo["MemFree"].to_i
372         @buffers = meminfo["Buffers"].to_i
373         @cached = meminfo["Cached"].to_i
374         @usage = ((@total - @free - @cached - @buffers) * 100) / @total.to_f
375       end
376     end
378     class Raggle < Widget
379       description "Unread posts in raggle"
380       option :feed_list_path, "Path to feeds list", ".raggle/feeds.yaml"
381       option :feed_cache_path, "Path to feeds cache", ".raggle/feed_cache.store"
382       field :count, "Ammount of unread posts", 0
383       default "@count"
385       init do
386         @feed_list_path = "#{ENV["HOME"]}/#@feed_list_path" if @feed_list_path[0] != ?/
387         feeds = YAML.load_file(@feed_list_path)
388         @feed_cache_path = "#{ENV["HOME"]}/#{@feed_cache_path}" if @feed_cache_path[0] != ?/
389         cache = PStore.new(@feed_cache_path)
390         cache.transaction(false) do
391           feeds.each do |feed|
392             cache[feed["url"]].each do |item|
393               @count += 1 unless item["read?"]
394             end
395           end
396         end
397       end
398     end
399   end
401   # Parse and manage command line options
402   class Options
403     include Enumerable
405     def initialize(args)
406       @options = {}
407       @options[:config] = "#{ENV["HOME"]}/.amazing.yml"
408       @options[:screens] = []
409       @options[:loglevel] = "info"
410       @options[:include] = []
411       @options[:update] = []
412       @args = args
413       @parser = OptionParser.new do |opts|
414         opts.on("-c", "--config FILE", "Configuration file (~/.amazing.yml)") do |config|
415           @options[:config] = config
416         end
417         opts.on("-s", "--screen ID", "Screen ID (0)") do |screen|
418           @options[:screens] << screen
419         end
420         opts.on("-l", "--log-level LEVEL", "Severity threshold (info)") do |level|
421           @options[:loglevel] = level
422         end
423         opts.on("-i", "--include SCRIPT", "Include a widgets script") do |script|
424           @options[:include] << script
425         end
426         opts.on("-u", "--update WIDGET", "Update a widget and exit") do |widget|
427           @options[:update] << widget
428         end
429         opts.on("-w", "--list-widgets", "List available widgets") do
430           @options[:listwidgets] = true
431         end
432         opts.on("-h", "--help", "You're looking at it") do
433           @options[:help] = true
434         end
435       end
436     end
438     def each
439       @options.keys.each do |key|
440         yield key
441       end
442     end
444     def parse(args=@args)
445       @parser.parse!(args)
446     end
448     def help
449       @parser.help
450     end
452     def [](option)
453       @options[option]
454     end
456     def []=(option, value)
457       @options[option] = value
458     end
459   end
461   # Command line interface runner
462   #
463   #   CLI.run(ARGV)
464   class CLI
465     def initialize(args)
466       @args = args
467       @log = Logger.new(STDOUT)
468       @options = Options.new(@args)
469       begin
470         @display = X11::DisplayName.new
471       rescue X11::EmptyDisplayName => e
472         @log.warn("#{e.message}, falling back on :0")
473         @display = X11::DisplayName.new(":0")
474       rescue X11::InvalidDisplayName => e
475         @log.fatal("#{e.message}, exiting")
476         exit 1
477       end
478     end
480     def run
481       trap("SIGINT") do
482         @log.fatal("Received SIGINT, exiting")
483         exit
484       end
485       @options.parse
486       show_help if @options[:help]
487       set_loglevel
488       parse_config
489       load_scripts
490       list_widgets if @options[:listwidgets]
491       setup_screens
492       wait_for_sockets
493       explicit_updates unless @options[:update].empty?
494       update_non_interval
495       count = 0
496       loop do
497         @config["widgets"].each do |widget_name, settings|
498           if settings["every"] && count % settings["every"] == 0
499             update_widget(widget_name)
500           end
501         end
502         count += 1
503         sleep 1
504       end
505     end
507     private
509     def show_help
510       puts @options.help
511       exit
512     end
514     def set_loglevel
515       begin
516         @log.level = Logger.const_get(@options[:loglevel].upcase)
517       rescue NameError
518         @log.error("Unsupported log level #{@options[:loglevel].inspect}")
519         @log.level = Logger::INFO
520       end
521     end
523     def load_scripts
524       scripts = @options[:include]
525       @config["include"].each do |script|
526         script = "#{File.dirname(@options[:config])}/#{script}" if script[0] != ?/
527         scripts << script
528       end
529       scripts.each do |script|
530         if File.exist?(script)
531           Widgets.module_eval(File.read(script))
532         else
533           @log.error("No such widget script #{script.inspect}")
534         end
535       end
536     end
538     def list_widgets
539       Widgets.constants.each do |widget|
540         if description = Widgets.const_get(widget).description
541           puts "#{widget}: #{description}"
542         else
543           puts widget
544         end
545       end
546       exit
547     end
549     def parse_config
550       @log.debug("Parsing configuration file")
551       begin
552         @config = YAML.load_file(@options[:config])
553       rescue
554         @log.fatal("Unable to parse configuration file, exiting")
555         exit 1
556       end
557       @config["include"] ||= []
558       @config["screens"] ||= []
559     end
561     def setup_screens
562       @screens = {}
563       @options[:screens].each do |screen|
564         @screens[screen.to_i] = Awesome.new(screen, @display.display)
565       end
566       if @screens.empty?
567         @config["screens"].each do |screen|
568           @screens[screen] = Awesome.new(screen, @display.display)
569         end
570       end
571       @screens[0] = Awesome.new if @screens.empty?
572     end
574     def wait_for_sockets
575       @log.debug("Waiting for awesome control socket for display #{@display.display}")
576       begin
577         Timeout.timeout(30) do
578           sleep 1 until File.exist?("#{ENV["HOME"]}/.awesome_ctl.#{@display.display}")
579           @log.debug("Got socket for display #{@display.display}")
580         end
581       rescue Timeout::Error
582         @log.fatal("Socket for display #{@display.display} not created within 30 seconds, exiting")
583         exit 1
584       end
585     end
587     def update_non_interval
588       @config["widgets"].each do |widget_name, settings|
589         next if settings["every"]
590         update_widget(widget_name)
591       end
592     end
594     def explicit_updates
595       @config["widgets"].each_key do |widget_name|
596         next unless @options[:update].include? widget_name
597         update_widget(widget_name, false)
598       end
599       exit
600     end
602     def update_widget(widget_name, threaded=true)
603       settings = @config["widgets"][widget_name]
604       begin
605         @screens.each do |screen, awesome|
606           @log.debug("Updating widget #{widget_name} of type #{settings["type"]} on screen #{screen}")
607           opts = settings["options"] || {}
608           field = settings["field"] || "default"
609           update = Proc.new do
610             widget = Widgets.const_get(settings["type"]).new(widget_name, settings["format"], opts)
611             awesome.widget_tell(widget_name, widget.formatize)
612           end
613           if threaded
614             Thread.new &update
615           else
616             update.call
617           end
618         end
619       rescue WidgetError => e
620         @log.error(settings["type"]) { e.message }
621       end
622     end
623   end
626 if $0 == __FILE__
627   Amazing::CLI.new(ARGV).run