Added todo entries
[amazing.git] / amazing.rb
blobb25e9667c8cd823765dabe37bf7e14913b295bca
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 # * Perhaps use a single awesome-client process and keep it open
107 # * Maybe keep custom widget options at same level as other options
108 # * More widgets, duh
110 # == Copying
112 # Copyright (C) 2008 Dag Odenhall <dag.odenhall@gmail.com>
113 # Licensed under the Academic Free License version 3.0
114 # http://www.rosenlaw.com/AFL3.0.htm
117 require 'optparse'
118 require 'logger'
119 require 'yaml'
120 require 'timeout'
121 require 'thread'
122 require 'pstore'
124 module Amazing
126   module X11
128     # Raised by DisplayName#new if called with empty argument, or without
129     # argument and ENV["DISPLAY"] is empty.
130     class EmptyDisplayName < ArgumentError
131     end
133     # Raised by DisplayName#new if format of argument or ENV["DISPLAY"] is
134     # invalid.
135     class InvalidDisplayName < ArgumentError
136     end
138     # Parse an X11 display name
139     #
140     #   display = DisplayName.new("hostname:displaynumber.screennumber")
141     #   display.hostname #=> "hostname"
142     #   display.display  #=> "displaynumber"
143     #   display.screen   #=> "screennumber"
144     #
145     # Without arguments, reads ENV["DISPLAY"]. With empty argument or
146     # DISPLAY environment, raises EmptyDisplayName. With invalid display name
147     # format, raises InvalidDisplayName. 
148     class DisplayName
149       attr_reader :hostname, :display, :screen
151       def initialize(display_name=ENV["DISPLAY"])
152         raise EmptyDisplayName, "No display name supplied" if ["", nil].include? display_name
153         @hostname, @display, @screen = display_name.scan(/^(.*):(\d+)(?:\.(\d+))?$/)[0]
154         raise InvalidDisplayName, "Invalid display name" if @display.nil?
155         @hostname = "localhost" if @hostname.empty?
156         @screen = "0" unless @screen
157       end
158     end
159   end
161   # Communicate with awesome
162   #
163   #   awesome = Awesome.new
164   #   awesome.widget_tell(widget_id, "Hello, world")
165   #   awesome = Awesome.new(1)
166   #   awesome.tag_view(3)
167   #   Awesome.new.client_zoom
168   class Awesome
169     attr_accessor :screen, :display
171     def initialize(screen=0, display=0)
172       @screen = screen.to_i
173       @display = display
174     end
176     def method_missing(method, *args)
177       IO.popen("env DISPLAY=#{display} awesome-client", IO::WRONLY) do |ac|
178         ac.puts "#@screen #{method} #{args.join(' ')}"
179       end
180     end
181   end
183   # Parse a /proc file
184   #
185   #   cpuinfo = ProcFile.new("cpuinfo")
186   #   cpuinfo[1]["model name"]
187   #   #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
188   class ProcFile
189     include Enumerable
191     def initialize(file)
192       file = "/proc/#{file}" if file[0] != ?/
193       @list = [{}]
194       File.readlines(file).each do |line|
195         if sep = line.index(":")
196           @list[-1][line[0..sep-1].strip] = line[sep+1..-1].strip
197         else
198           @list << {}
199         end
200       end
201       @list.pop if @list[-1].empty?
202     end
204     def each
205       @list.each do |section|
206         yield section
207       end
208     end
210     def [](section)
211       @list[section]
212     end
213   end
215   # Raised by widgets, and is then rescued and logged
216   class WidgetError < Exception
217   end
219   # Parent class for widget construction, example:
220   #
221   #   class Clock < Widget
222   #     description "Displays date and time"
223   #     option :time_format, "Time format as described in DATE(1)", "%R"
224   #     field :time, "Formatted time"
225   #     default "@time"
226   # 
227   #     init do
228   #       @time = Time.now.strftime(@time_format)
229   #       raise WidgetError, "An error occured!" if some_error?
230   #     end
231   #   end
232   class Widget
233     def initialize(identifier=nil, format=nil, opts={})
234       @identifier, @format = identifier, format
235       self.class.options.each do |key, value|
236         value = opts[key.to_s] || value[:default]
237         instance_variable_set "@#{key}".to_sym, value
238       end
239       self.class.fields.each do |key, value|
240         instance_variable_set "@#{key}".to_sym, value[:default]
241       end
242       instance_eval(&self.class.init) if self.class.init
243     end
245     def self.description(description=nil)
246       if description
247         @description = description
248       else
249         @description
250       end
251     end
253     def self.option(name, description=nil, default=nil)
254       @options ||= {}
255       @options[name] = {:description => description, :default => default}
256     end
258     def self.options
259       @options || {}
260     end
262     def self.field(name, description=nil, default=nil)
263       @fields ||= {}
264       @fields[name] = {:description => description, :default => default}
265     end
267     def self.fields
268       @fields || {}
269     end
271     def self.default(format=nil, &block)
272       if format
273         @default = format
274       elsif block
275         @default = block
276       else
277         @default
278       end
279     end
281     def self.init(&block)
282       if block
283         @init = block
284       else
285         @init
286       end
287     end
289     def formatize
290       if @format
291         instance_eval(@format)
292       else
293         case self.class.default
294         when Proc
295           instance_eval(&self.class.default)
296         when String
297           instance_eval(self.class.default)
298         end
299       end
300     end
301   end
303   module Widgets
304     class ALSA < Widget
305       description "Various data for the ALSA mixer"
306       option :mixer, "ALSA mixer name", "Master"
307       field :volume, "Volume in percentage", 0
308       default "@volume"
310       init do
311         IO.popen("amixer get #@mixer", IO::RDONLY) do |am|
312           out = am.read
313           volumes = out.scan(/\[(\d+)%\]/).flatten
314           volumes.each {|vol| @volume += vol.to_i }
315           @volume = @volume / volumes.size
316         end
317       end
318     end
320     class Battery < Widget
321       description "Remaining battery power in percentage"
322       option :battery, "Battery number", 1
323       field :percentage, "Power percentage", 0
324       default "@percentage"
326       init do
327         batinfo = ProcFile.new("acpi/battery/BAT#@battery/info")[0]
328         batstate = ProcFile.new("acpi/battery/BAT#@battery/state")[0]
329         remaining = batstate["remaining capacity"].to_i
330         lastfull = batinfo["last full capacity"].to_i
331         @percentage = (remaining * 100) / lastfull.to_f
332       end
333     end
335     class Clock < Widget
336       description "Displays date and time"
337       option :time_format, "Time format as described in DATE(1)", "%R"
338       field :time, "Formatted time"
339       default "@time"
341       init do
342         @time = Time.now.strftime(@time_format)
343       end
344     end
346     class Maildir < Widget
347       description "Mail count in maildirs"
348       option :directories, "Globs of maildirs" # TODO: does a default make sense?
349       field :count, "Ammount of mail in searched directories", 0
350       default "@count"
352       init do
353         raise WidgetError, "No directories configured" unless @directories
354         @directories.each do |glob|
355           glob = "#{ENV["HOME"]}/#{glob}" if glob[0] != ?/
356           @count += Dir["#{glob}/*"].size
357         end
358       end
359     end
361     class Memory < Widget
362       description "Various memory related data"
363       field :total, "Total kilobytes of memory", 0
364       field :free, "Free kilobytes of memory", 0
365       field :buffers, nil, 0 # TODO: description
366       field :cached, nil, 0 # TODO: description
367       field :usage, "Percentage of used memory", 0
368       default "@usage"
370       init do
371         meminfo = ProcFile.new("meminfo")[0]
372         @total = meminfo["MemTotal"].to_i
373         @free = meminfo["MemFree"].to_i
374         @buffers = meminfo["Buffers"].to_i
375         @cached = meminfo["Cached"].to_i
376         @usage = ((@total - @free - @cached - @buffers) * 100) / @total.to_f
377       end
378     end
380     class Raggle < Widget
381       description "Unread posts in raggle"
382       option :feed_list_path, "Path to feeds list", ".raggle/feeds.yaml"
383       option :feed_cache_path, "Path to feeds cache", ".raggle/feed_cache.store"
384       field :count, "Ammount of unread posts", 0
385       default "@count"
387       init do
388         @feed_list_path = "#{ENV["HOME"]}/#@feed_list_path" if @feed_list_path[0] != ?/
389         feeds = YAML.load_file(@feed_list_path)
390         @feed_cache_path = "#{ENV["HOME"]}/#{@feed_cache_path}" if @feed_cache_path[0] != ?/
391         cache = PStore.new(@feed_cache_path)
392         cache.transaction(false) do
393           feeds.each do |feed|
394             cache[feed["url"]].each do |item|
395               @count += 1 unless item["read?"]
396             end
397           end
398         end
399       end
400     end
401   end
403   # Parse and manage command line options
404   class Options
405     include Enumerable
407     def initialize(args)
408       @options = {}
409       @options[:config] = "#{ENV["HOME"]}/.amazing.yml"
410       @options[:screens] = []
411       @options[:loglevel] = "info"
412       @options[:include] = []
413       @options[:update] = []
414       @args = args
415       @parser = OptionParser.new do |opts|
416         opts.on("-c", "--config FILE", "Configuration file (~/.amazing.yml)") do |config|
417           @options[:config] = config
418         end
419         opts.on("-s", "--screen ID", "Screen ID (0)") do |screen|
420           @options[:screens] << screen
421         end
422         opts.on("-l", "--log-level LEVEL", "Severity threshold (info)") do |level|
423           @options[:loglevel] = level
424         end
425         opts.on("-i", "--include SCRIPT", "Include a widgets script") do |script|
426           @options[:include] << script
427         end
428         opts.on("-u", "--update WIDGET", "Update a widget and exit") do |widget|
429           @options[:update] << widget
430         end
431         opts.on("-w", "--list-widgets", "List available widgets") do
432           @options[:listwidgets] = true
433         end
434         opts.on("-h", "--help", "You're looking at it") do
435           @options[:help] = true
436         end
437       end
438     end
440     def each
441       @options.keys.each do |key|
442         yield key
443       end
444     end
446     def parse(args=@args)
447       @parser.parse!(args)
448     end
450     def help
451       @parser.help
452     end
454     def [](option)
455       @options[option]
456     end
458     def []=(option, value)
459       @options[option] = value
460     end
461   end
463   # Command line interface runner
464   #
465   #   CLI.run(ARGV)
466   class CLI
467     def initialize(args)
468       @args = args
469       @log = Logger.new(STDOUT)
470       @options = Options.new(@args)
471       begin
472         @display = X11::DisplayName.new
473       rescue X11::EmptyDisplayName => e
474         @log.warn("#{e.message}, falling back on :0")
475         @display = X11::DisplayName.new(":0")
476       rescue X11::InvalidDisplayName => e
477         @log.fatal("#{e.message}, exiting")
478         exit 1
479       end
480     end
482     def run
483       trap("SIGINT") do
484         @log.fatal("Received SIGINT, exiting")
485         exit
486       end
487       @options.parse
488       show_help if @options[:help]
489       set_loglevel
490       parse_config
491       load_scripts
492       list_widgets if @options[:listwidgets]
493       setup_screens
494       wait_for_sockets
495       explicit_updates unless @options[:update].empty?
496       update_non_interval
497       count = 0
498       loop do
499         @config["widgets"].each do |widget_name, settings|
500           if settings["every"] && count % settings["every"] == 0
501             update_widget(widget_name)
502           end
503         end
504         count += 1
505         sleep 1
506       end
507     end
509     private
511     def show_help
512       puts @options.help
513       exit
514     end
516     def set_loglevel
517       begin
518         @log.level = Logger.const_get(@options[:loglevel].upcase)
519       rescue NameError
520         @log.error("Unsupported log level #{@options[:loglevel].inspect}")
521         @log.level = Logger::INFO
522       end
523     end
525     def load_scripts
526       scripts = @options[:include]
527       @config["include"].each do |script|
528         script = "#{File.dirname(@options[:config])}/#{script}" if script[0] != ?/
529         scripts << script
530       end
531       scripts.each do |script|
532         if File.exist?(script)
533           Widgets.module_eval(File.read(script))
534         else
535           @log.error("No such widget script #{script.inspect}")
536         end
537       end
538     end
540     def list_widgets
541       Widgets.constants.each do |widget|
542         if description = Widgets.const_get(widget).description
543           puts "#{widget}: #{description}"
544         else
545           puts widget
546         end
547       end
548       exit
549     end
551     def parse_config
552       @log.debug("Parsing configuration file")
553       begin
554         @config = YAML.load_file(@options[:config])
555       rescue
556         @log.fatal("Unable to parse configuration file, exiting")
557         exit 1
558       end
559       @config["include"] ||= []
560       @config["screens"] ||= []
561     end
563     def setup_screens
564       @screens = {}
565       @options[:screens].each do |screen|
566         @screens[screen.to_i] = Awesome.new(screen, @display.display)
567       end
568       if @screens.empty?
569         @config["screens"].each do |screen|
570           @screens[screen] = Awesome.new(screen, @display.display)
571         end
572       end
573       @screens[0] = Awesome.new if @screens.empty?
574     end
576     def wait_for_sockets
577       @log.debug("Waiting for awesome control socket for display #{@display.display}")
578       begin
579         Timeout.timeout(30) do
580           sleep 1 until File.exist?("#{ENV["HOME"]}/.awesome_ctl.#{@display.display}")
581           @log.debug("Got socket for display #{@display.display}")
582         end
583       rescue Timeout::Error
584         @log.fatal("Socket for display #{@display.display} not created within 30 seconds, exiting")
585         exit 1
586       end
587     end
589     def update_non_interval
590       @config["widgets"].each do |widget_name, settings|
591         next if settings["every"]
592         update_widget(widget_name)
593       end
594     end
596     def explicit_updates
597       @config["widgets"].each_key do |widget_name|
598         next unless @options[:update].include? widget_name
599         update_widget(widget_name, false)
600       end
601       exit
602     end
604     def update_widget(widget_name, threaded=true)
605       settings = @config["widgets"][widget_name]
606       begin
607         @screens.each do |screen, awesome|
608           @log.debug("Updating widget #{widget_name} of type #{settings["type"]} on screen #{screen}")
609           opts = settings["options"] || {}
610           field = settings["field"] || "default"
611           update = Proc.new do
612             widget = Widgets.const_get(settings["type"]).new(widget_name, settings["format"], opts)
613             awesome.widget_tell(widget_name, widget.formatize)
614           end
615           if threaded
616             Thread.new &update
617           else
618             update.call
619           end
620         end
621       rescue WidgetError => e
622         @log.error(settings["type"]) { e.message }
623       end
624     end
625   end
628 if $0 == __FILE__
629   Amazing::CLI.new(ARGV).run